├── .github └── workflows │ └── swift.yml ├── .gitignore ├── .spi.yml ├── LICENSE.md ├── Logo.png ├── Package.swift ├── README.md └── Sources └── Turbocharger ├── Sources ├── Alignment │ ├── FirstTextMidline.swift │ └── VariadicAlignmentID.swift ├── Core │ ├── ArrayBuilder.swift │ ├── EquatableBox.swift │ └── IdentifiableBox.swift ├── DynamicProperty │ ├── FormatTransform.swift │ ├── OptionalObservedObject.swift │ ├── OptionalStateObject.swift │ └── PublishedState.swift ├── Extensions │ ├── Alignment+Extensions.swift │ ├── EdgeInsets+Extensions.swift │ └── Transaction+Extensions.swift ├── View │ ├── AdaptiveStack.swift │ ├── AsyncButton.swift │ ├── AsyncForEach.swift │ ├── CALayerRepresentable.swift │ ├── CollectionView.swift │ ├── CollectionViewCompositionalLayout.swift │ ├── CollectionViewCoordinator.swift │ ├── CollectionViewHostingConfigurationCoordinator.swift │ ├── CollectionViewLayout.swift │ ├── CollectionViewListLayout.swift │ ├── CollectionViewRepresentable.swift │ ├── FlowStack.swift │ ├── FluidGradient.swift │ ├── ForEach.swift │ ├── HVStack.swift │ ├── LabeledView.swift │ ├── MarqueeHStack.swift │ ├── MarqueeText.swift │ ├── PlatformViewRepresentable.swift │ ├── ProposedSizeReader.swift │ ├── RadialStack.swift │ ├── ResultAdapter.swift │ ├── WeightedHStack.swift │ ├── WeightedPriority.swift │ └── WeightedVStack.swift └── ViewModifier │ ├── Accessibility.swift │ ├── AlignmentOffset.swift │ ├── Badge.swift │ ├── BlurModifier.swift │ ├── Hidden.swift │ ├── Mask.swift │ ├── OnAppearAndChange.swift │ ├── ProposedSizeObserver.swift │ ├── RotationRelativeFrameModifier.swift │ ├── SafeArea.swift │ ├── ScaledFrame.swift │ ├── Shimmer.swift │ ├── SizeThatFitsRelativeFrameModifier.swift │ └── VibrancyEffect.swift └── module.swift /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Build 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | runs-on: macos-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Xcode version 18 | run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer 19 | - name: Show available destinations 20 | run: xcodebuild -scheme Turbocharger -showdestinations 21 | - name: Build for macOS 22 | run: xcodebuild -scheme Turbocharger -destination 'platform=macOS' build 23 | - name: Build for Catalyst 24 | run: xcodebuild -scheme Turbocharger -destination 'platform=macOS,variant=Mac Catalyst' build 25 | - name: Build for iOS 26 | run: xcodebuild -scheme Turbocharger -destination 'platform=iOS Simulator,name=iPhone 16' build 27 | - name: Build for watchOS 28 | run: xcodebuild -scheme Turbocharger -destination 'platform=watchOS Simulator,name=Apple Watch Ultra 2 (49mm)' build 29 | - name: Build for tvOS 30 | run: xcodebuild -scheme Turbocharger -destination 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)' build 31 | - name: Build for visionOS 32 | run: xcodebuild -scheme Turbocharger -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/ 8 | .netrc 9 | Package.resolved -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [Turbocharger] 5 | platform: macos 6 | - documentation_targets: [Turbocharger] 7 | platform: ios 8 | - documentation_targets: [Turbocharger] 9 | platform: tvos 10 | - documentation_targets: [Turbocharger] 11 | platform: watchos 12 | - documentation_targets: [Turbocharger] 13 | platform: visionos 14 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (C) 2022, Nathan Tannar 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are 7 | met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | * Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in 13 | the documentation and/or other materials provided with the 14 | distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 17 | IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 18 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 19 | PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 20 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 21 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 22 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 23 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 24 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 25 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nathantannar4/Turbocharger/ef4550296eeeb362b572d2abeba0a91ea6e13e07/Logo.png -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Turbocharger", 7 | platforms: [ 8 | .iOS(.v13), 9 | .macOS(.v10_15), 10 | .macCatalyst(.v13), 11 | .tvOS(.v13), 12 | .watchOS(.v6), 13 | .visionOS(.v1) 14 | ], 15 | products: [ 16 | .library( 17 | name: "Turbocharger", 18 | targets: ["Turbocharger"] 19 | ), 20 | ], 21 | dependencies: [ 22 | .package(url: "https://github.com/nathantannar4/Engine", from: "2.1.6"), 23 | ], 24 | targets: [ 25 | .target( 26 | name: "Turbocharger", 27 | dependencies: [ 28 | "Engine" 29 | ] 30 | ) 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Turbocharger 4 | 5 | `Turbocharger` aims accelerate SwiftUI development by providing commonly desired views and view modifiers. Highlights include an `AdaptiveStack` to better support dynamic type and `WeightedVStack`/`WeightedHStack` for relational layouts. 6 | 7 | > Built for performance and backwards compatibility using [Engine](https://github.com/nathantannar4/Engine) 8 | 9 | ## See Also 10 | 11 | - [Ignition](https://github.com/nathantannar4/Ignition) 12 | - [Transmission](https://github.com/nathantannar4/Transmission) 13 | 14 | ## Requirements 15 | 16 | - Deployment target: iOS 13.0, macOS 10.15, tvOS 13.0, or watchOS 6.0 17 | - Xcode 15+ 18 | 19 | ## Installation 20 | 21 | ### Xcode Projects 22 | 23 | Select `File` -> `Swift Packages` -> `Add Package Dependency` and enter `https://github.com/nathantannar4/Turbocharger`. 24 | 25 | ### Swift Package Manager Projects 26 | 27 | You can add `Turbocharger` as a package dependency in your `Package.swift` file: 28 | 29 | ```swift 30 | let package = Package( 31 | //... 32 | dependencies: [ 33 | .package(url: "https://github.com/nathantannar4/Turbocharger"), 34 | ], 35 | targets: [ 36 | .target( 37 | name: "YourPackageTarget", 38 | dependencies: [ 39 | .product(name: "Turbocharger", package: "Turbocharger"), 40 | ], 41 | //... 42 | ), 43 | //... 44 | ], 45 | //... 46 | ) 47 | ``` 48 | 49 | ## Documentation 50 | 51 | Detailed documentation is available [here](https://swiftpackageindex.com/nathantannar4/Turbocharger/main/documentation/turbocharger). 52 | 53 | ## Introduction to Turbocharger 54 | 55 | `Turbocharger` was started with the two goals. 1) To expand the standard API that SwiftUI provides to what many would commonly desired or need; and 2) To demonstrate how to use [Engine](https://github.com/nathantannar4/Engine) to make reusable components that are backwards compatible. 56 | 57 | ### LabeledView 58 | 59 | ```swift 60 | /// The ``ViewStyle`` for ``LabeledView`` 61 | public protocol LabeledViewStyle: ViewStyle where Configuration == LabeledViewStyleConfiguration { 62 | associatedtype Configuration = Configuration 63 | } 64 | 65 | /// The ``ViewStyledView.Configuration`` for ``LabeledView`` 66 | public struct LabeledViewStyleConfiguration { 67 | /// A type-erased label of a ``LabeledView`` 68 | public struct Label: ViewAlias { } 69 | public var label: Label 70 | 71 | /// A type-erased content of a ``LabeledView`` 72 | public struct Content: ViewAlias { } 73 | public var content: Content 74 | } 75 | 76 | /// A backwards compatible port of `LabeledContent` 77 | public struct LabeledView: View { 78 | 79 | public init( 80 | @ViewBuilder content: () -> Content, 81 | @ViewBuilder label: () -> Label 82 | ) 83 | } 84 | ``` 85 | 86 | ### HVStack/AdaptiveStack 87 | 88 | ```swift 89 | /// A view that arranges its subviews in a vertical or horizontal line. 90 | @frozen 91 | public struct HVStack: View { 92 | 93 | public init( 94 | axis: Axis, 95 | alignment: Alignment = .center, 96 | spacing: CGFloat? = nil, 97 | @ViewBuilder content: () -> Content 98 | ) 99 | } 100 | 101 | /// A view that arranges its subviews in a horizontal line when such a layout 102 | /// would fit the available space. If there is not enough space, it arranges it's subviews 103 | /// in a vertical line. 104 | @frozen 105 | public struct AdaptiveStack: View { 106 | 107 | public init( 108 | alignment: Alignment = .center, 109 | spacing: CGFloat? = nil, 110 | @ViewBuilder content: () -> Content 111 | ) 112 | } 113 | ``` 114 | 115 | ### WeightedHStack/WeightedVStack 116 | 117 | ```swift 118 | /// A view that arranges its subviews in a horizontal line a width 119 | /// that is relative to its `LayoutWeightPriority`. 120 | /// 121 | /// By default, all subviews will be arranged with equal width. 122 | /// 123 | @frozen 124 | public struct WeightedHStack: View { 125 | 126 | public init( 127 | alignment: VerticalAlignment = .center, 128 | spacing: CGFloat? = nil, 129 | @ViewBuilder content: () -> Content 130 | ) 131 | } 132 | 133 | /// A view that arranges its subviews in a vertical line a height 134 | /// that is relative to its `LayoutWeightPriority`. 135 | /// 136 | /// By default, all subviews will be arranged with equal height. 137 | /// 138 | @frozen 139 | public struct WeightedVStack: View { 140 | 141 | public init( 142 | alignment: HorizontalAlignment = .center, 143 | spacing: CGFloat? = nil, 144 | @ViewBuilder content: () -> Content 145 | ) 146 | } 147 | 148 | extension View { 149 | @ViewBuilder 150 | public func layoutWeight(_ value: Double) -> some View 151 | } 152 | 153 | ``` 154 | 155 | ### FlowStack 156 | 157 | ```swift 158 | /// A view that arranges its subviews along multiple horizontal lines. 159 | @frozen 160 | public struct FlowStack: View { 161 | 162 | public init( 163 | alignment: Alignment = .center, 164 | spacing: CGFloat? = nil, 165 | @ViewBuilder content: () -> Content 166 | ) 167 | } 168 | ``` 169 | 170 | ### RadialStack 171 | 172 | ```swift 173 | /// A view that arranges its subviews along a radial circumference. 174 | @frozen 175 | public struct RadialStack: View { 176 | 177 | public init(radius: CGFloat? = nil, @ViewBuilder content: () -> Content) 178 | } 179 | ``` 180 | 181 | ### OptionalAdapter 182 | 183 | ```swift 184 | /// A view maps an `Optional` value to it's `Content` or `Placeholder`. 185 | @frozen 186 | public struct OptionalAdapter< 187 | T, 188 | Content: View, 189 | Placeholder: View 190 | >: View { 191 | 192 | @inlinable 193 | public init( 194 | _ value: T?, 195 | @ViewBuilder content: (T) -> Content, 196 | @ViewBuilder placeholder: () -> Placeholder 197 | ) 198 | 199 | @inlinable 200 | public init( 201 | _ value: Binding, 202 | @ViewBuilder content: (Binding) -> Content, 203 | @ViewBuilder placeholder: () -> Placeholder 204 | ) 205 | } 206 | 207 | extension OptionalAdapter where Placeholder == EmptyView { 208 | @inlinable 209 | public init( 210 | _ value: T?, 211 | @ViewBuilder content: (T) -> Content 212 | ) 213 | 214 | @inlinable 215 | public init( 216 | _ value: Binding, 217 | @ViewBuilder content: (Binding) -> Content 218 | ) 219 | } 220 | ``` 221 | 222 | ### ResultAdapter 223 | 224 | ```swift 225 | /// A view maps a `Result` value to it's `SuccessContent` or `FailureContent`. 226 | @frozen 227 | public struct ResultAdapter< 228 | SuccessContent: View, 229 | FailureContent: View 230 | >: View { 231 | 232 | @inlinable 233 | public init( 234 | _ value: Result, 235 | @ViewBuilder content: (Success) -> SuccessContent, 236 | @ViewBuilder placeholder: (Failure) -> FailureContent 237 | ) 238 | 239 | @inlinable 240 | public init( 241 | _ value: Binding>, 242 | @ViewBuilder content: (Binding) -> SuccessContent, 243 | @ViewBuilder placeholder: (Binding) -> FailureContent 244 | ) 245 | } 246 | 247 | extension ResultAdapter where FailureContent == EmptyView { 248 | @inlinable 249 | public init( 250 | _ value: Result, 251 | @ViewBuilder content: (Success) -> SuccessContent 252 | ) 253 | 254 | @inlinable 255 | public init( 256 | _ value: Binding>, 257 | @ViewBuilder content: (Binding) -> SuccessContent 258 | ) 259 | } 260 | ``` 261 | 262 | ### BindingTransform 263 | 264 | ```swift 265 | public protocol BindingTransform { 266 | associatedtype Input 267 | associatedtype Output 268 | 269 | func get(_ value: Input) -> Output 270 | func set(_ newValue: Output, oldValue: @autoclosure () -> Input) throws -> Input 271 | } 272 | 273 | extension Binding { 274 | @inlinable 275 | public func projecting( 276 | _ transform: Transform 277 | ) -> Binding where Transform.Input == Value 278 | 279 | @inlinable 280 | public func isNil() -> Binding where Optional == Value 281 | 282 | @inlinable 283 | public func isNotNil() -> Binding where Optional == Value 284 | 285 | @inlinable 286 | public func map(_ keyPath: WritableKeyPath) -> Binding 287 | } 288 | ``` 289 | 290 | ### SafeAreaPadding 291 | 292 | ```swift 293 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) 294 | extension View { 295 | @inlinable 296 | public func safeAreaPadding(_ edgeInsets: EdgeInsets) -> some View 297 | 298 | @inlinable 299 | public func safeAreaPadding(_ length: CGFloat = 16) -> some View 300 | 301 | @inlinable 302 | public func safeAreaPadding(_ edges: Edge.Set, _ length: CGFloat = 16) -> some View 303 | } 304 | ``` 305 | 306 | ### Badge 307 | 308 | ```swift 309 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) 310 | extension View { 311 | @inlinable 312 | public func badge( 313 | alignment: Alignment = .topTrailing, 314 | anchor: UnitPoint = UnitPoint(x: 0.25, y: 0.25), 315 | scale: CGFloat = 1.2, 316 | @ViewBuilder label: () -> Label 317 | ) -> some View 318 | } 319 | ``` 320 | 321 | ### Accessibility 322 | 323 | ```swift 324 | extension View { 325 | /// Disables accessibility elements from being generated, even when an assistive technology is running 326 | @inlinable 327 | public func accessibilityDisabled() -> some View 328 | 329 | /// Optionally uses the specified string to identify the view. 330 | @inlinable 331 | public func accessibilityIdentifier(_ identifier: String?) -> ModifiedContent 332 | 333 | /// Optionally adds a label to the view that describes its contents. 334 | @_disfavoredOverload 335 | @inlinable 336 | public func accessibilityLabel(_ label: S?) -> ModifiedContent 337 | 338 | /// Optionally adds a textual description of the value that the view contains. 339 | @_disfavoredOverload 340 | @inlinable 341 | public func accessibilityValue(_ value: S?) -> ModifiedContent 342 | 343 | /// Optionally adds an accessibility action to the view. 344 | @inlinable 345 | public func accessibilityAction(named name: LocalizedStringKey?, _ handler: @escaping () -> Void) -> ModifiedContent 346 | 347 | /// Optionally adds an accessibility action to the view. 348 | @_disfavoredOverload 349 | @inlinable 350 | public func accessibilityAction(named name: S?, _ handler: @escaping () -> Void) -> ModifiedContent 351 | ``` 352 | 353 | ### And Many More 354 | 355 | See the source files for more. 356 | 357 | ## License 358 | 359 | Distributed under the BSD 2-Clause License. See ``LICENSE.md`` for more information. 360 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/Alignment/FirstTextMidline.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | 7 | extension VerticalAlignment { 8 | private enum FirstTextMidline: AlignmentID { 9 | static func defaultValue(in context: ViewDimensions) -> CGFloat { 10 | let dy = context[.lastTextBaseline] - context[.firstTextBaseline] 11 | let lineHeight = context.height - dy 12 | return lineHeight / 2 13 | } 14 | } 15 | 16 | public static let firstTextMidline = VerticalAlignment(FirstTextMidline.self) 17 | } 18 | 19 | // MARK: - Previews 20 | 21 | struct FirstTextMidline_Previews: PreviewProvider { 22 | static var previews: some View { 23 | VStack { 24 | HStack(alignment: .firstTextMidline) { 25 | Color.red 26 | .frame(width: 32, height: 32) 27 | 28 | Text("Lorem ipsum") 29 | } 30 | 31 | HStack(alignment: .firstTextMidline) { 32 | Color.red 33 | .frame(width: 32, height: 32) 34 | 35 | Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.") 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/Alignment/VariadicAlignmentID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | 7 | /// An `AlignmentID` that is resolved from multiple values 8 | /// 9 | /// > Tip: Use ``VariadicAlignmentID`` to create alignments 10 | /// similar to `.firstTextBaseline` 11 | public protocol VariadicAlignmentID: AlignmentID { 12 | static func reduce(value: inout CGFloat?, n: Int, nextValue: CGFloat) 13 | } 14 | 15 | private struct DefaultAlignmentID: AlignmentID { 16 | static func defaultValue(in context: ViewDimensions) -> CGFloat { 0 } 17 | } 18 | 19 | extension VariadicAlignmentID { 20 | public static func reduce(value: inout CGFloat?, n: Int, nextValue: CGFloat) { 21 | DefaultAlignmentID._combineExplicit(childValue: nextValue, n, into: &value) 22 | } 23 | 24 | public static func _combineExplicit( 25 | childValue: CGFloat, 26 | _ n: Int, 27 | into parentValue: inout CGFloat? 28 | ) { 29 | reduce(value: &parentValue, n: n, nextValue: childValue) 30 | } 31 | } 32 | 33 | extension View { 34 | 35 | /// A modifier that transforms a vertical alignment to another 36 | @inlinable 37 | public func alignmentGuide( 38 | _ g: VerticalAlignment, 39 | value: VerticalAlignment 40 | ) -> some View { 41 | alignmentGuide(g) { $0[value] } 42 | } 43 | 44 | /// A modifier that transforms a horizontal alignment to another 45 | @inlinable 46 | public func alignmentGuide( 47 | _ g: HorizontalAlignment, 48 | value: HorizontalAlignment 49 | ) -> some View { 50 | alignmentGuide(g) { $0[value] } 51 | } 52 | } 53 | 54 | // MARK: - Previews 55 | 56 | struct SecondTextBaseline: VariadicAlignmentID { 57 | static func defaultValue(in context: ViewDimensions) -> CGFloat { 58 | context[.firstTextBaseline] 59 | } 60 | 61 | static func reduce(value: inout CGFloat?, n: Int, nextValue: CGFloat) { 62 | if n == 1 { 63 | value = nextValue 64 | } 65 | } 66 | } 67 | 68 | extension VerticalAlignment { 69 | static let secondTextBaseline = VerticalAlignment(SecondTextBaseline.self) 70 | } 71 | 72 | struct VariadicAlignmentID_Previews: PreviewProvider { 73 | static var previews: some View { 74 | VStack(spacing: 48) { 75 | HStack(alignment: .firstTextBaseline) { 76 | Text("Label") 77 | 78 | VStack(alignment: .trailing) { 79 | Text("One") 80 | Text("Two") 81 | Text("Three") 82 | } 83 | .font(.title) 84 | } 85 | 86 | HStack(alignment: .secondTextBaseline) { 87 | Text("Label") 88 | 89 | VStack(alignment: .trailing) { 90 | Group { 91 | Text("One") 92 | Text("Two") 93 | Text("Three") 94 | } 95 | .alignmentGuide(.secondTextBaseline) { d in 96 | d[VerticalAlignment.firstTextBaseline] 97 | } 98 | } 99 | .font(.title) 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/Core/ArrayBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | 7 | /// A custom parameter attribute that constructs an array from closures. 8 | @frozen 9 | @resultBuilder 10 | public struct ArrayBuilder { 11 | 12 | @inlinable 13 | public static func buildBlock() -> [Optional] { 14 | [] 15 | } 16 | 17 | @inlinable 18 | public static func buildPartialBlock( 19 | first: [Optional] 20 | ) -> [Optional] { 21 | first 22 | } 23 | 24 | @inlinable 25 | public static func buildPartialBlock( 26 | accumulated: [Optional], 27 | next: [Optional] 28 | ) -> [Optional] { 29 | accumulated + next 30 | } 31 | 32 | @inlinable 33 | public static func buildExpression( 34 | _ expression: Element 35 | ) -> [Optional] { 36 | [expression] 37 | } 38 | 39 | @inlinable 40 | public static func buildEither( 41 | first component: [Optional] 42 | ) -> [Optional] { 43 | component 44 | } 45 | 46 | @inlinable 47 | public static func buildEither( 48 | second component: [Optional] 49 | ) -> [Optional] { 50 | component 51 | } 52 | 53 | @inlinable 54 | public static func buildOptional( 55 | _ component: [Optional]? 56 | ) -> [Optional] { 57 | component ?? [] 58 | } 59 | 60 | @inlinable 61 | public static func buildLimitedAvailability( 62 | _ component: [Optional] 63 | ) -> [Optional] { 64 | component 65 | } 66 | 67 | @inlinable 68 | public static func buildArray( 69 | _ components: [Optional] 70 | ) -> [Optional] { 71 | components 72 | } 73 | 74 | @inlinable 75 | public static func buildBlock( 76 | _ components: [Optional]... 77 | ) -> [Optional] { 78 | components.flatMap { $0 } 79 | } 80 | 81 | public static func buildFinalResult( 82 | _ component: [Optional] 83 | ) -> [Element] { 84 | component.compactMap { $0 } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/Core/EquatableBox.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | 7 | @frozen 8 | public struct EquatableBox: Equatable { 9 | public var value: Value 10 | 11 | @inlinable 12 | public init(_ value: Value) { 13 | self.value = value 14 | } 15 | 16 | public static func == (lhs: EquatableBox, rhs: EquatableBox) -> Bool { 17 | return false 18 | } 19 | } 20 | 21 | extension EquatableBox: Identifiable where Value: Identifiable { 22 | public var id: Value.ID { value.id } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/Core/IdentifiableBox.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import Foundation 6 | 7 | @frozen 8 | public struct IdentifiableBox: Identifiable { 9 | public var value: Value 10 | public var keyPath: KeyPath 11 | 12 | public var id: ID { value[keyPath: keyPath] } 13 | 14 | @inlinable 15 | public init(_ value: Value, id keyPath: KeyPath) { 16 | self.value = value 17 | self.keyPath = keyPath 18 | } 19 | } 20 | 21 | extension IdentifiableBox: Equatable where Value: Equatable { } 22 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/DynamicProperty/FormatTransform.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | /// A ``BindingTransform`` that transforms the value 9 | /// with a `ParseableFormatStyle` 10 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) 11 | public struct FormatTransform: BindingTransform where F.FormatInput: Equatable, F.FormatOutput == String { 12 | public typealias Input = F.FormatInput 13 | public typealias Output = F.FormatOutput 14 | 15 | @usableFromInline 16 | var format: F 17 | @usableFromInline 18 | var defaultValue: Input? 19 | 20 | @inlinable 21 | public init(format: F, defaultValue: Input? = nil) { 22 | self.format = format 23 | self.defaultValue = defaultValue 24 | } 25 | 26 | public func get(_ value: Input) -> Output { 27 | format.format(value) 28 | } 29 | 30 | public func set(_ newValue: Output, oldValue: @autoclosure () -> Input) throws -> Input { 31 | do { 32 | return try format.parseStrategy.parse(newValue) 33 | } catch { 34 | if let defaultValue = defaultValue { 35 | return defaultValue 36 | } 37 | throw error 38 | } 39 | } 40 | } 41 | 42 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) 43 | extension Binding { 44 | @inlinable 45 | public func format( 46 | _ format: F, 47 | defaultValue: F.FormatInput? = nil 48 | ) -> Binding where F.FormatInput: Equatable, F.FormatOutput == String, Value == F.FormatInput { 49 | projecting(FormatTransform(format: format, defaultValue: defaultValue)) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/DynamicProperty/OptionalObservedObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Combine 7 | 8 | /// A property wrapper that subscribes to an optional observable 9 | /// object and invalidates a view whenever the observable object changes. 10 | @propertyWrapper 11 | @frozen 12 | public struct OptionalObservedObject: DynamicProperty { 13 | 14 | @usableFromInline 15 | class Storage: ObservableObject { 16 | weak var value: ObjectType? { 17 | didSet { 18 | if oldValue !== value { 19 | value.map { bind(to: $0) } 20 | objectWillChange.send() 21 | } 22 | } 23 | } 24 | 25 | var cancellable: AnyCancellable? 26 | 27 | @usableFromInline 28 | init(value: ObjectType?) { 29 | self.value = value 30 | value.map { bind(to: $0) } 31 | } 32 | 33 | func bind(to object: ObjectType) { 34 | cancellable = object.objectWillChange 35 | .sink { [unowned self] _ in 36 | self.objectWillChange.send() 37 | } 38 | } 39 | } 40 | 41 | @usableFromInline 42 | var storage: ObservedObject 43 | 44 | @inlinable 45 | public init(wrappedValue: ObjectType?) { 46 | storage = ObservedObject(wrappedValue: Storage(value: wrappedValue)) 47 | } 48 | 49 | public var wrappedValue: ObjectType? { 50 | get { storage.wrappedValue.value } 51 | nonmutating set { storage.wrappedValue.value = newValue } 52 | } 53 | 54 | public var projectedValue: Binding { 55 | storage.projectedValue.value 56 | } 57 | 58 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) 59 | public static var _propertyBehaviors: UInt32 { 60 | StateObject._propertyBehaviors 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/DynamicProperty/OptionalStateObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Combine 7 | 8 | /// A property wrapper that instantiates an optional observable object 9 | /// and invalidates a view whenever the observable object changes. 10 | @propertyWrapper 11 | @frozen 12 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) 13 | @MainActor @preconcurrency 14 | public struct OptionalStateObject: DynamicProperty { 15 | 16 | @usableFromInline 17 | @MainActor @preconcurrency 18 | class Storage: ObservableObject { 19 | var value: ObjectType? { 20 | didSet { 21 | if oldValue !== value { 22 | value.map { bind(to: $0) } 23 | objectWillChange.send() 24 | } 25 | } 26 | } 27 | 28 | var cancellable: AnyCancellable? 29 | 30 | @usableFromInline 31 | init(value: @autoclosure @escaping () -> ObjectType?) { 32 | self.value = value() 33 | self.value.map { bind(to: $0) } 34 | } 35 | 36 | func bind(to object: ObjectType) { 37 | cancellable = object.objectWillChange 38 | .sink { [unowned self] _ in 39 | self.objectWillChange.send() 40 | } 41 | } 42 | } 43 | 44 | @usableFromInline 45 | var storage: StateObject 46 | 47 | @inlinable 48 | @MainActor @preconcurrency 49 | public init(wrappedValue: @autoclosure @escaping () -> ObjectType?) { 50 | storage = StateObject(wrappedValue: Storage(value: wrappedValue())) 51 | } 52 | 53 | @MainActor @preconcurrency 54 | public var wrappedValue: ObjectType? { 55 | get { storage.wrappedValue.value } 56 | nonmutating set { storage.wrappedValue.value = newValue } 57 | } 58 | 59 | @MainActor @preconcurrency 60 | public var projectedValue: Binding { 61 | storage.projectedValue.value 62 | } 63 | 64 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) 65 | public nonisolated static var _propertyBehaviors: UInt32 { 66 | #if swift(>=5.9) 67 | MainActor.assumeIsolated { 68 | StateObject._propertyBehaviors 69 | } 70 | #else 71 | StateObject._propertyBehaviors 72 | #endif 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/DynamicProperty/PublishedState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Combine 7 | 8 | /// A property wrapper that can read and write a value but does 9 | /// not invalidate a view when changed. 10 | /// 11 | /// > Tip: Use ``PublishedState`` to improve performance 12 | /// when your view does not need to be invalidated for every change. 13 | /// Instead, use ``View/onReceive`` with ``PublishedState/publisher`` 14 | /// 15 | @propertyWrapper 16 | @frozen 17 | public struct PublishedState: DynamicProperty { 18 | 19 | public typealias Publisher = Published.Publisher 20 | 21 | @usableFromInline 22 | final class Storage: ObservableObject { 23 | @Published var value: Value 24 | 25 | @usableFromInline 26 | init(value: Value) { 27 | self.value = value 28 | } 29 | } 30 | 31 | @usableFromInline 32 | var storage: State 33 | 34 | @inlinable 35 | public init(wrappedValue: Value) { 36 | storage = State(wrappedValue: Storage(value: wrappedValue)) 37 | } 38 | 39 | public var wrappedValue: Value { 40 | get { storage.wrappedValue.value } 41 | nonmutating set { storage.wrappedValue.value = newValue } 42 | } 43 | 44 | public var projectedValue: Binding { 45 | storage.projectedValue.value 46 | } 47 | 48 | public var publisher: Publisher { 49 | storage.wrappedValue.$value 50 | } 51 | 52 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) 53 | public static var _propertyBehaviors: UInt32 { 54 | State._propertyBehaviors 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/Extensions/Alignment+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | 7 | extension NSTextAlignment { 8 | public init( 9 | alignment: HorizontalAlignment, 10 | layoutDirection: LayoutDirection 11 | ) { 12 | switch alignment { 13 | case .center: 14 | self.init(alignment: TextAlignment.center, layoutDirection: layoutDirection) 15 | case .trailing: 16 | self.init(alignment: TextAlignment.trailing, layoutDirection: layoutDirection) 17 | default: 18 | self.init(alignment: TextAlignment.leading, layoutDirection: layoutDirection) 19 | } 20 | } 21 | 22 | public init( 23 | alignment: TextAlignment, 24 | layoutDirection: LayoutDirection 25 | ) { 26 | switch alignment { 27 | case .center: 28 | self = .center 29 | default: 30 | switch layoutDirection { 31 | case .rightToLeft: 32 | if alignment == .leading { 33 | self = .right 34 | } else { 35 | self = .left 36 | } 37 | default: 38 | if alignment == .leading { 39 | self = .left 40 | } else { 41 | self = .right 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/Extensions/EdgeInsets+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | 7 | extension EdgeInsets { 8 | public static let zero = EdgeInsets() 9 | 10 | public static func horizontal(_ inset: CGFloat) -> EdgeInsets { 11 | EdgeInsets(top: 0, leading: inset, bottom: 0, trailing: inset) 12 | } 13 | 14 | public static func vertical(_ inset: CGFloat) -> EdgeInsets { 15 | EdgeInsets(top: inset, leading: 0, bottom: inset, trailing: 0) 16 | } 17 | } 18 | 19 | #if os(macOS) 20 | extension NSEdgeInsets { 21 | public init( 22 | edgeInsets: EdgeInsets, 23 | layoutDirection: LayoutDirection 24 | ) { 25 | self.init( 26 | top: edgeInsets.top, 27 | left: layoutDirection == .leftToRight ? edgeInsets.leading : edgeInsets.trailing, 28 | bottom: edgeInsets.bottom, 29 | right: layoutDirection == .leftToRight ? edgeInsets.trailing : edgeInsets.leading 30 | ) 31 | } 32 | } 33 | #else 34 | extension UIEdgeInsets { 35 | public init( 36 | edgeInsets: EdgeInsets, 37 | layoutDirection: LayoutDirection 38 | ) { 39 | self.init( 40 | top: edgeInsets.top, 41 | left: layoutDirection == .leftToRight ? edgeInsets.leading : edgeInsets.trailing, 42 | bottom: edgeInsets.bottom, 43 | right: layoutDirection == .leftToRight ? edgeInsets.trailing : edgeInsets.leading 44 | ) 45 | } 46 | } 47 | #endif 48 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/Extensions/Transaction+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | #if !os(watchOS) 9 | 10 | @inline(__always) 11 | public func withAnimation( 12 | _ animation: Animation = .default, 13 | _ body: () throws -> Result, 14 | completion: @escaping () -> Void 15 | ) rethrows -> Result { 16 | try withTransaction(Transaction(animation: animation), body, completion: completion) 17 | } 18 | 19 | @inline(__always) 20 | public func withTransaction( 21 | _ transaction: Transaction, 22 | _ body: () throws -> Result, 23 | completion: @escaping () -> Void 24 | ) rethrows -> Result { 25 | defer { withCATransaction(completion) } 26 | return try withTransaction(transaction, body) 27 | } 28 | 29 | #endif 30 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/AdaptiveStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | /// A view that arranges its subviews in a horizontal line when such a layout 9 | /// would fit the available space. If there is not enough space, it arranges it's subviews 10 | /// in a vertical line. 11 | @frozen 12 | public struct AdaptiveStack: View { 13 | 14 | public var alignment: Alignment 15 | public var spacing: CGFloat? 16 | public var content: Content 17 | 18 | @Environment(\.self) var environment 19 | 20 | var axis: Axis { 21 | #if os(iOS) 22 | if environment.horizontalSizeClass != .regular { 23 | if #available(iOS 15.0, *) { 24 | if environment.dynamicTypeSize.isAccessibilitySize { 25 | return .vertical 26 | } 27 | } else if UIApplication.shared.preferredContentSizeCategory.isAccessibilityCategory { 28 | return .vertical 29 | } 30 | } 31 | #endif 32 | return .horizontal 33 | } 34 | 35 | public init( 36 | alignment: Alignment = .center, 37 | spacing: CGFloat? = nil, 38 | @ViewBuilder content: () -> Content 39 | ) { 40 | self.alignment = alignment 41 | self.spacing = spacing 42 | self.content = content() 43 | } 44 | 45 | public var body: some View { 46 | AdaptiveStackBody( 47 | alignment: alignment, 48 | spacing: spacing, 49 | content: content, 50 | axis: axis 51 | ) 52 | } 53 | } 54 | 55 | private struct AdaptiveStackBody: VersionedView { 56 | 57 | var alignment: Alignment 58 | var spacing: CGFloat? 59 | var content: Content 60 | var axis: Axis 61 | 62 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 63 | var v4Body: some View { 64 | LayoutAdapter { 65 | switch axis { 66 | case .vertical: 67 | _VStackLayout(alignment: alignment.horizontal, spacing: spacing) 68 | case .horizontal: 69 | LayoutThatFits( 70 | in: .horizontal, 71 | _HStackLayout(alignment: alignment.vertical, spacing: spacing), 72 | _VStackLayout(alignment: alignment.horizontal, spacing: spacing) 73 | ) 74 | } 75 | } content: { 76 | content 77 | } 78 | } 79 | 80 | var v1Body: some View { 81 | HVStack(axis: axis, alignment: alignment, spacing: spacing) { 82 | content 83 | } 84 | } 85 | } 86 | 87 | // MARK: - Previews 88 | 89 | struct AdaptiveStack_Previews: PreviewProvider { 90 | struct Preview: View { 91 | @State private var isRestricted = false 92 | 93 | var content: some View { 94 | Group { 95 | Text("Layout") 96 | Text("That") 97 | Text("Fits") 98 | } 99 | .lineLimit(1) 100 | .padding() 101 | .foregroundColor(.white) 102 | .background(Color.blue) 103 | } 104 | 105 | var body: some View { 106 | VStack { 107 | Toggle(isOn: $isRestricted) { 108 | EmptyView() 109 | } 110 | 111 | VStack { 112 | AdaptiveStack { 113 | content 114 | } 115 | 116 | if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) { 117 | AdaptiveStack { 118 | content 119 | } 120 | .dynamicTypeSize(.accessibility1) 121 | } 122 | } 123 | .frame(width: isRestricted ? 100 : nil) 124 | .background(Color.gray) 125 | .animation(.default, value: isRestricted) 126 | 127 | Spacer() 128 | } 129 | .padding() 130 | } 131 | } 132 | 133 | static var previews: some View { 134 | Preview() 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/AsyncButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | @frozen 9 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) 10 | public struct AsyncButton: View { 11 | 12 | public var label: Label 13 | public var role: ButtonRole? 14 | public var animation: Animation? 15 | public var action: () async -> Void 16 | 17 | @StateOrBinding private var isLoading: Bool 18 | @State private var trigger: UInt = 0 19 | 20 | public init( 21 | role: ButtonRole? = nil, 22 | animation: Animation? = .default, 23 | action: @escaping () async -> Void, 24 | @ViewBuilder label: () -> Label 25 | ) { 26 | self.role = role 27 | self._isLoading = .init(false) 28 | self.animation = animation 29 | self.action = action 30 | self.label = label() 31 | } 32 | 33 | public init( 34 | role: ButtonRole? = nil, 35 | isLoading: Binding, 36 | animation: Animation? = .default, 37 | action: @escaping () async -> Void, 38 | @ViewBuilder label: () -> Label 39 | ) { 40 | self.role = role 41 | self._isLoading = .init(isLoading) 42 | self.animation = animation 43 | self.action = action 44 | self.label = label() 45 | } 46 | 47 | public var body: some View { 48 | Button(role: role) { 49 | trigger &+= 1 50 | withAnimation(animation) { 51 | isLoading = true 52 | } 53 | } label: { 54 | label 55 | } 56 | .disabled(isLoading) 57 | .task(id: trigger, priority: .userInitiated) { 58 | guard trigger > 0 else { return } 59 | await action() 60 | withAnimation(animation) { 61 | isLoading = false 62 | } 63 | } 64 | } 65 | } 66 | 67 | // MARK: - Previews 68 | 69 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) 70 | struct AsyncButton_Previews: PreviewProvider { 71 | static var previews: some View { 72 | Preview() 73 | } 74 | 75 | struct Preview: View { 76 | @State var isLoading = false 77 | 78 | var body: some View { 79 | VStack { 80 | AsyncButton { 81 | print("started") 82 | try? await Task.sleep(nanoseconds: 1_000_000_000) 83 | print("finished") 84 | } label: { 85 | Text("Load") 86 | } 87 | 88 | AsyncButton(isLoading: $isLoading, animation: .spring) { 89 | print("started") 90 | try? await Task.sleep(nanoseconds: 1_000_000_000) 91 | print("finished") 92 | } label: { 93 | Text("Load") 94 | .overlay { 95 | if isLoading { 96 | ProgressView() 97 | } 98 | } 99 | } 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/AsyncForEach.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | 7 | public struct AsyncForEach< 8 | Data: RandomAccessCollection, 9 | Content: View 10 | >: View where Data.Element: Identifiable { 11 | 12 | var values: [AsyncValue] 13 | var content: (Optional) -> Content 14 | 15 | public init( 16 | _ data: Optional, 17 | placeholders: Int, 18 | @ViewBuilder content: @escaping (Optional) -> Content 19 | ) { 20 | var values = data?.compactMap { 21 | AsyncValue.value($0) 22 | } ?? [] 23 | let placeholders = (0...placeholder(.init(index: $0)) 25 | } 26 | values.append(contentsOf: placeholders) 27 | self.values = values 28 | self.content = content 29 | } 30 | 31 | public var body: some View { 32 | ForEach(values) { value in 33 | content(value.asOptional()) 34 | } 35 | } 36 | } 37 | 38 | extension AsyncForEach { 39 | public init< 40 | _Data: RandomAccessCollection, 41 | ID: Hashable 42 | >( 43 | _ data: Optional<_Data>, 44 | id: KeyPath<_Data.Element, ID>, 45 | placeholders: Int, 46 | @ViewBuilder content: @escaping (Optional<_Data.Element>) -> Content 47 | ) where Data == Array> { 48 | let data = data?.compactMap { 49 | IdentifiableBox($0, id: id) 50 | } 51 | self.init(data, placeholders: placeholders) { box in 52 | content(box?.value) 53 | } 54 | } 55 | } 56 | 57 | extension AsyncForEach: DynamicViewContent { 58 | public var data: [Data.Element] { 59 | values.compactMap { $0.asOptional() } 60 | } 61 | } 62 | 63 | enum AsyncValue: Identifiable { 64 | case value(Value) 65 | struct Placeholder: Hashable, Sendable { 66 | var index: Int 67 | } 68 | case placeholder(Placeholder) 69 | 70 | var id: AnyHashable { 71 | switch self { 72 | case .value(let value): 73 | return AnyHashable(value.id) 74 | case .placeholder(let placeholder): 75 | return AnyHashable(placeholder) 76 | } 77 | } 78 | 79 | func asOptional() -> Value? { 80 | switch self { 81 | case .value(let value): 82 | return value 83 | case .placeholder: 84 | return nil 85 | } 86 | } 87 | } 88 | 89 | extension AsyncValue: Equatable where Value: Equatable { } 90 | 91 | extension AsyncValue: Hashable where Value: Hashable { } 92 | 93 | extension AsyncValue: Sendable where Value: Sendable { } 94 | 95 | // MARK: - Previews 96 | 97 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 8.0, *) 98 | struct AsyncForEach_Previews: PreviewProvider { 99 | static var previews: some View { 100 | Preview() 101 | } 102 | 103 | struct Preview: View { 104 | @State var numbers: [Int] = [] 105 | @State var isLoading = true 106 | 107 | var body: some View { 108 | ScrollView { 109 | VStack { 110 | AsyncForEach(numbers, id: \.self, placeholders: isLoading ? 3 : 0) { number in 111 | HStack { 112 | Circle() 113 | .fill(Color.primary.opacity(0.16)) 114 | .frame(width: 32, height: 32) 115 | 116 | Text(number?.description ?? String(repeating: "*", count: 12)) 117 | } 118 | .frame(maxWidth: .infinity, alignment: .leading) 119 | .shimmer(isActive: number == nil) 120 | } 121 | } 122 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 123 | .padding() 124 | } 125 | .animation(.default, value: numbers) 126 | .overlay( 127 | HStack { 128 | Button { 129 | isLoading.toggle() 130 | } label: { 131 | Text("Toggle Loading") 132 | } 133 | 134 | Button { 135 | numbers += [numbers.count, numbers.count + 1, numbers.count + 2] 136 | } label: { 137 | Text("Add") 138 | } 139 | } 140 | .padding() 141 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) 142 | ) 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/CALayerRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | #if !os(watchOS) 9 | 10 | /// A wrapper for a QuartzCore layer that you use to integrate that layer into your 11 | /// SwiftUI view hierarchy. 12 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 13 | @available(watchOS, unavailable) 14 | @MainActor @preconcurrency 15 | public protocol CALayerRepresentable: PrimitiveView { 16 | associatedtype CALayerType: CALayer 17 | 18 | /// Configures the layers initial state. 19 | /// 20 | /// Configure the view using your app's current data and contents of the 21 | /// `context` parameter. The system calls this method only once, when it 22 | /// creates your layer for the first time. For all subsequent updates, the 23 | /// system calls the ``CALayerRepresentable/updateCALayer(_:context:)`` 24 | /// method. 25 | /// 26 | @MainActor @preconcurrency func makeCALayer(_ layer: CALayerType, context: Context) 27 | 28 | /// Updates the layer with new information. 29 | /// 30 | /// > Note: This protocol implementation is optional 31 | /// 32 | @MainActor @preconcurrency func updateCALayer(_ layer: CALayerType, context: Context) 33 | 34 | associatedtype Coordinator = Void 35 | 36 | @MainActor @preconcurrency func makeCoordinator() -> Coordinator 37 | 38 | /// Cleans up the layer in anticipation of it's removal. 39 | @MainActor @preconcurrency static func dismantleCALayer(_ layer: CALayerType, coordinator: Coordinator) 40 | 41 | typealias Context = CALayerRepresentableContext 42 | } 43 | 44 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 45 | @available(watchOS, unavailable) 46 | extension CALayerRepresentable where Coordinator == Void { 47 | public func makeCoordinator() -> Coordinator { () } 48 | } 49 | 50 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 51 | @available(watchOS, unavailable) 52 | extension CALayerRepresentable { 53 | public static func dismantleCALayer(_ layer: CALayerType, coordinator: Coordinator) { } 54 | } 55 | 56 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 57 | @available(watchOS, unavailable) 58 | @frozen 59 | public struct CALayerRepresentableContext< 60 | Representable: CALayerRepresentable 61 | > { 62 | public var coordinator: Representable.Coordinator 63 | public var environment: EnvironmentValues 64 | } 65 | 66 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 67 | @available(watchOS, unavailable) 68 | extension CALayerRepresentable { 69 | 70 | private var content: CALayerRepresentableBody { 71 | CALayerRepresentableBody(representable: self) 72 | } 73 | 74 | public static func makeView( 75 | view: _GraphValue, 76 | inputs: _ViewInputs 77 | ) -> _ViewOutputs { 78 | CALayerRepresentableBody._makeView(view: view[\.content], inputs: inputs) 79 | } 80 | 81 | public static func makeViewList( 82 | view: _GraphValue, 83 | inputs: _ViewListInputs 84 | ) -> _ViewListOutputs { 85 | CALayerRepresentableBody._makeViewList(view: view[\.content], inputs: inputs) 86 | } 87 | 88 | public static func viewListCount( 89 | inputs: _ViewListCountInputs 90 | ) -> Int? { 91 | CALayerRepresentableBody._viewListCount(inputs: inputs) 92 | } 93 | } 94 | 95 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 96 | @available(watchOS, unavailable) 97 | private struct CALayerRepresentableBody< 98 | Representable: CALayerRepresentable 99 | >: View { 100 | var representable: Representable 101 | 102 | @StateObject var storage: Storage 103 | @Environment(\.self) var environment 104 | 105 | init(representable: Representable) { 106 | self.representable = representable 107 | self._storage = StateObject( 108 | wrappedValue: Storage( 109 | coordinator: representable.makeCoordinator() 110 | ) 111 | ) 112 | } 113 | 114 | var body: some View { 115 | _CALayerView(type: Representable.CALayerType.self) { layer in 116 | let context = CALayerRepresentableContext( 117 | coordinator: storage.coordinator, 118 | environment: environment 119 | ) 120 | if storage.layer == nil { 121 | storage.layer = layer 122 | representable.makeCALayer( 123 | layer, 124 | context: context 125 | ) 126 | } 127 | representable.updateCALayer( 128 | layer, 129 | context: context 130 | ) 131 | } 132 | } 133 | 134 | final class Storage: ObservableObject { 135 | var layer: Representable.CALayerType! 136 | var coordinator: Representable.Coordinator 137 | 138 | init(coordinator: Representable.Coordinator) { 139 | self.coordinator = coordinator 140 | } 141 | 142 | deinit { 143 | if let layer { 144 | Representable.dismantleCALayer( 145 | layer, 146 | coordinator: coordinator 147 | ) 148 | } 149 | } 150 | } 151 | } 152 | 153 | // MARK: - Previews 154 | 155 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 156 | @available(watchOS, unavailable) 157 | struct GradientLayer: CALayerRepresentable { 158 | func makeCALayer(_ layer: CAGradientLayer, context: Context) { 159 | #if os(macOS) 160 | layer.colors = [NSColor.green.cgColor, NSColor.blue.cgColor] 161 | #else 162 | layer.colors = [UIColor.green.cgColor, UIColor.blue.cgColor] 163 | #endif 164 | } 165 | 166 | func updateCALayer(_ layer: CAGradientLayer, context: Context) { 167 | 168 | } 169 | } 170 | 171 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 172 | @available(watchOS, unavailable) 173 | struct GradientLayer_Previews: PreviewProvider { 174 | static var previews: some View { 175 | GradientLayer() 176 | } 177 | } 178 | 179 | #endif 180 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/CollectionViewCompositionalLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | /// A ``CollectionViewLayout`` 9 | @available(iOS 14.0, *) 10 | @available(macOS, unavailable) 11 | @available(tvOS, unavailable) 12 | @available(watchOS, unavailable) 13 | @frozen 14 | public struct CollectionViewCompositionalLayout: CollectionViewLayout { 15 | 16 | public var axis: Axis 17 | public var estimatedDimension: CGFloat 18 | public var itemSpacing: CGFloat 19 | public var sectionSpacing: CGFloat 20 | public var contentInsets: EdgeInsets 21 | public var pinnedViews: Set 22 | 23 | @inlinable 24 | public init( 25 | axis: Axis = .vertical, 26 | estimatedDimension: CGFloat? = nil, 27 | itemSpacing: CGFloat = 0, 28 | sectionSpacing: CGFloat = 0, 29 | contentInsets: EdgeInsets = .zero, 30 | pinnedViews: Set = [] 31 | ) { 32 | self.axis = axis 33 | switch axis { 34 | case .vertical: 35 | self.estimatedDimension = estimatedDimension ?? 60 36 | case .horizontal: 37 | self.estimatedDimension = estimatedDimension ?? 400 38 | } 39 | self.itemSpacing = itemSpacing 40 | self.sectionSpacing = sectionSpacing 41 | self.contentInsets = contentInsets 42 | self.pinnedViews = pinnedViews 43 | } 44 | 45 | #if os(iOS) 46 | public func makeUICollectionViewLayout( 47 | context: Context, 48 | options: CollectionViewLayoutOptions 49 | ) -> UICollectionViewCompositionalLayout { 50 | let itemSize = NSCollectionLayoutSize( 51 | widthDimension: axis == .vertical ? .fractionalWidth(1.0) : .estimated(estimatedDimension), 52 | heightDimension: axis == .vertical ? .estimated(estimatedDimension) : .fractionalHeight(1.0) 53 | ) 54 | 55 | let item = NSCollectionLayoutItem( 56 | layoutSize: itemSize 57 | ) 58 | let group: NSCollectionLayoutGroup = { 59 | switch axis { 60 | case .vertical: 61 | NSCollectionLayoutGroup.vertical( 62 | layoutSize: itemSize, 63 | subitems: [item] 64 | ) 65 | case .horizontal: 66 | NSCollectionLayoutGroup.horizontal( 67 | layoutSize: itemSize, 68 | subitems: [item] 69 | ) 70 | } 71 | }() 72 | 73 | let section = NSCollectionLayoutSection(group: group) 74 | section.interGroupSpacing = itemSpacing 75 | section.contentInsets = NSDirectionalEdgeInsets(contentInsets) 76 | if #available(iOS 16.0, *) { 77 | section.supplementaryContentInsetsReference = .none 78 | } else { 79 | section.supplementariesFollowContentInsets = false 80 | } 81 | 82 | for supplementaryView in options.supplementaryViews { 83 | let item = NSCollectionLayoutBoundarySupplementaryItem( 84 | layoutSize: itemSize, 85 | elementKind: supplementaryView.kind, 86 | alignment: { 87 | switch supplementaryView.alignment { 88 | case .top: 89 | return .top 90 | case .topLeading: 91 | return .topLeading 92 | case .topTrailing: 93 | return .topLeading 94 | case .bottom: 95 | return .bottom 96 | case .bottomLeading: 97 | return .bottomLeading 98 | case .bottomTrailing: 99 | return .bottomTrailing 100 | case .leading: 101 | return .leading 102 | case .trailing: 103 | return .trailing 104 | default: 105 | return .none 106 | } 107 | }(), 108 | absoluteOffset: supplementaryView.offset 109 | ) 110 | item.contentInsets = NSDirectionalEdgeInsets(supplementaryView.contentInset) 111 | item.zIndex = supplementaryView.zIndex 112 | item.pinToVisibleBounds = pinnedViews.contains(supplementaryView.id) 113 | section.boundarySupplementaryItems.append(item) 114 | } 115 | 116 | let configuration = UICollectionViewCompositionalLayoutConfiguration() 117 | configuration.interSectionSpacing = sectionSpacing 118 | switch axis { 119 | case .vertical: 120 | configuration.scrollDirection = .vertical 121 | case .horizontal: 122 | configuration.scrollDirection = .horizontal 123 | } 124 | let layout = UICollectionViewCompositionalLayout( 125 | section: section, 126 | configuration: configuration 127 | ) 128 | return layout 129 | } 130 | 131 | public func makeUICollectionView( 132 | context: Context, 133 | options: CollectionViewLayoutOptions 134 | ) -> UICollectionView { 135 | 136 | let layout = makeUICollectionViewLayout(context: context, options: options) 137 | let uiCollectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) 138 | uiCollectionView.clipsToBounds = false 139 | uiCollectionView.keyboardDismissMode = .interactive 140 | uiCollectionView.backgroundColor = nil 141 | return uiCollectionView 142 | } 143 | 144 | public func updateUICollectionView( 145 | _ collectionView: UICollectionView, 146 | context: Context 147 | ) { } 148 | #endif 149 | } 150 | 151 | @available(iOS 14.0, *) 152 | @available(macOS, unavailable) 153 | @available(tvOS, unavailable) 154 | @available(watchOS, unavailable) 155 | extension CollectionViewLayout where Self == CollectionViewCompositionalLayout { 156 | 157 | public static var compositional: CollectionViewCompositionalLayout { .compositional() } 158 | 159 | public static func compositional( 160 | axis: Axis = .vertical, 161 | estimatedDimension: CGFloat? = nil, 162 | spacing: CGFloat = 0, 163 | contentInsets: EdgeInsets = .zero, 164 | pinnedViews: Set = [] 165 | ) -> CollectionViewCompositionalLayout { 166 | .compositional( 167 | axis: axis, 168 | estimatedDimension: estimatedDimension, 169 | itemSpacing: spacing, 170 | contentInsets: contentInsets, 171 | pinnedViews: pinnedViews 172 | ) 173 | } 174 | 175 | public static func compositional( 176 | axis: Axis = .vertical, 177 | estimatedDimension: CGFloat? = nil, 178 | itemSpacing: CGFloat = 0, 179 | sectionSpacing: CGFloat = 0, 180 | contentInsets: EdgeInsets = .zero, 181 | pinnedViews: Set = [] 182 | ) -> CollectionViewCompositionalLayout { 183 | CollectionViewCompositionalLayout( 184 | axis: axis, 185 | estimatedDimension: estimatedDimension, 186 | itemSpacing: itemSpacing, 187 | sectionSpacing: sectionSpacing, 188 | contentInsets: contentInsets, 189 | pinnedViews: pinnedViews 190 | ) 191 | } 192 | } 193 | 194 | // MARK: - Previews 195 | 196 | #if os(iOS) 197 | @available(iOS 15.0, *) 198 | @available(macOS, unavailable) 199 | @available(tvOS, unavailable) 200 | @available(watchOS, unavailable) 201 | struct CollectionViewLayout_Previews: PreviewProvider { 202 | static var previews: some View { 203 | CollectionView( 204 | .compositional(spacing: 12, pinnedViews: [.header]), 205 | sections: [[1, 2, 3]], 206 | id: \.self 207 | ) { id in 208 | CellView(axis: .vertical, text: "Cell \(id)") 209 | } header: { _ in 210 | HeaderFooter(axis: .vertical, isHeader: true) 211 | } footer: { _ in 212 | HeaderFooter(axis: .vertical, isHeader: false) 213 | } 214 | 215 | CollectionView( 216 | .compositional( 217 | itemSpacing: 12, 218 | sectionSpacing: 4, 219 | contentInsets: .init(top: 8, leading: 8, bottom: 8, trailing: 8) 220 | ), 221 | sections: [[1, 2, 3], [4, 5, 6]], 222 | id: \.self 223 | ) { id in 224 | CellView(axis: .vertical, text: "Cell \(id)") 225 | } header: { _ in 226 | HeaderFooter(axis: .vertical, isHeader: true) 227 | } footer: { _ in 228 | HeaderFooter(axis: .vertical, isHeader: false) 229 | } 230 | 231 | CollectionView( 232 | .compositional(axis: .horizontal, spacing: 12, pinnedViews: [.header]), 233 | sections: [[1, 2, 3], [4, 5, 6]], 234 | id: \.self 235 | ) { id in 236 | CellView(axis: .horizontal, text: "Cell \(id)") 237 | } header: { _ in 238 | HeaderFooter(axis: .horizontal, isHeader: true) 239 | } footer: { _ in 240 | HeaderFooter(axis: .horizontal, isHeader: false) 241 | } 242 | 243 | ScrollView { 244 | CollectionView( 245 | .compositional(axis: .horizontal, spacing: 12, pinnedViews: [.header]), 246 | sections: [[1, 2, 3], [4, 5, 6]], 247 | id: \.self 248 | ) { id in 249 | CellView(axis: .horizontal, text: "Cell \(id)") 250 | } header: { _ in 251 | HeaderFooter(axis: .horizontal, isHeader: true) 252 | } footer: { _ in 253 | HeaderFooter(axis: .horizontal, isHeader: false) 254 | } 255 | } 256 | 257 | CollectionView( 258 | .compositional( 259 | spacing: 12, 260 | pinnedViews: [.header] 261 | ), 262 | sections: [(0..<3).map { IdentifiableBox($0, id: \.self) }], 263 | supplementaryViews: [ 264 | .header, 265 | .custom( 266 | "banner", 267 | alignment: .topLeading, 268 | offset: CGPoint(x: 0, y: -24) 269 | ), 270 | .custom( 271 | "card", 272 | alignment: .bottom, 273 | offset: CGPoint(x: 0, y: 24) 274 | ) 275 | ] 276 | ) { id in 277 | CellView(axis: .vertical, text: "Cell \(id.value)") 278 | } header: { _ in 279 | HeaderFooter(axis: .vertical, isHeader: true) 280 | } footer: { _ in 281 | HeaderFooter(axis: .vertical, isHeader: false) 282 | } supplementaryView: { id, index in 283 | Color.purple 284 | .overlay { Text("\(id)") } 285 | .frame(height: 100) 286 | .border(Color.pink, width: 5) 287 | } 288 | } 289 | 290 | struct CellView: View { 291 | var axis: Axis 292 | var text: String 293 | 294 | var body: some View { 295 | Text(text) 296 | .frame(maxWidth: axis == .vertical ? .infinity : nil, maxHeight: axis == .horizontal ? .infinity : nil) 297 | .padding() 298 | .background(Color.red) 299 | } 300 | } 301 | 302 | struct HeaderFooter: View { 303 | var axis: Axis 304 | var isHeader: Bool 305 | 306 | var body: some View { 307 | Text(isHeader ? "Header" : "Footer") 308 | .frame(maxWidth: axis == .vertical ? .infinity : nil, minHeight: 24, maxHeight: axis == .horizontal ? .infinity : nil) 309 | .background(Color.blue) 310 | } 311 | } 312 | } 313 | #endif 314 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/CollectionViewLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | @available(iOS 14.0, *) 9 | @available(macOS, unavailable) 10 | @available(tvOS, unavailable) 11 | @available(watchOS, unavailable) 12 | @MainActor @preconcurrency 13 | public protocol CollectionViewLayout { 14 | 15 | #if os(iOS) 16 | associatedtype UICollectionViewLayoutType: UICollectionViewLayout 17 | associatedtype UICollectionViewType: UICollectionView 18 | associatedtype UICollectionViewCellType: UICollectionViewCell = UICollectionViewCell 19 | associatedtype UICollectionViewSupplementaryViewType: UICollectionReusableView = UICollectionViewCell 20 | 21 | @MainActor @preconcurrency func makeUICollectionViewLayout( 22 | context: Context, 23 | options: CollectionViewLayoutOptions 24 | ) -> UICollectionViewLayoutType 25 | 26 | @MainActor @preconcurrency func makeUICollectionView( 27 | context: Context, 28 | options: CollectionViewLayoutOptions 29 | ) -> UICollectionViewType 30 | 31 | @MainActor @preconcurrency func updateUICollectionView( 32 | _ collectionView: UICollectionViewType, 33 | context: Context 34 | ) 35 | 36 | @MainActor @preconcurrency func updateUICollectionViewCell( 37 | _ collectionView: UICollectionViewType, 38 | cell: UICollectionViewCellType, 39 | indexPath: IndexPath, 40 | context: Context 41 | ) 42 | 43 | @MainActor @preconcurrency func updateUICollectionViewSupplementaryView( 44 | _ collectionView: UICollectionViewType, 45 | supplementaryView: UICollectionViewSupplementaryViewType, 46 | kind: String, 47 | indexPath: IndexPath, 48 | context: Context 49 | ) 50 | #endif 51 | 52 | typealias Context = CollectionViewLayoutContext 53 | 54 | } 55 | 56 | #if os(iOS) 57 | @available(iOS 14.0, *) 58 | extension CollectionViewLayout { 59 | 60 | public func updateUICollectionViewCell( 61 | _ collectionView: UICollectionViewType, 62 | cell: UICollectionViewCellType, 63 | indexPath: IndexPath, 64 | context: Context 65 | ) { } 66 | 67 | public func updateUICollectionViewSupplementaryView( 68 | _ collectionView: UICollectionViewType, 69 | supplementaryView: UICollectionViewSupplementaryViewType, 70 | kind: String, 71 | indexPath: IndexPath, 72 | context: Context 73 | ) { } 74 | } 75 | #endif 76 | 77 | @frozen 78 | @available(iOS 14.0, *) 79 | @available(macOS, unavailable) 80 | @available(tvOS, unavailable) 81 | @available(watchOS, unavailable) 82 | public struct CollectionViewLayoutContext { 83 | public var environment: EnvironmentValues 84 | public var transaction: Transaction 85 | } 86 | 87 | @frozen 88 | @available(iOS 14.0, *) 89 | @available(macOS, unavailable) 90 | @available(tvOS, unavailable) 91 | @available(watchOS, unavailable) 92 | public struct CollectionViewLayoutOptions { 93 | public var supplementaryViews: [CollectionViewSupplementaryView] 94 | 95 | public init( 96 | supplementaryViews: [CollectionViewSupplementaryView] = [] 97 | ) { 98 | self.supplementaryViews = supplementaryViews 99 | } 100 | } 101 | 102 | @available(iOS 14.0, *) 103 | @available(macOS, unavailable) 104 | @available(tvOS, unavailable) 105 | @available(watchOS, unavailable) 106 | public struct CollectionViewSupplementaryView: Hashable, Sendable { 107 | 108 | public enum ID: Hashable, Sendable { 109 | case header 110 | case footer 111 | case custom(String) 112 | 113 | public var kind: String { 114 | #if os(iOS) 115 | switch self { 116 | case .header: 117 | return UICollectionView.elementKindSectionHeader 118 | case .footer: 119 | return UICollectionView.elementKindSectionFooter 120 | case .custom(let id): 121 | return id 122 | } 123 | #else 124 | fatalError("unreachable") 125 | #endif 126 | } 127 | } 128 | 129 | public var id: ID 130 | public var alignment: Alignment 131 | public var offset: CGPoint 132 | public var contentInset: EdgeInsets 133 | public var zIndex: Int 134 | 135 | private init( 136 | id: ID, 137 | alignment: Alignment, 138 | offset: CGPoint = .zero, 139 | contentInset: EdgeInsets = .zero, 140 | zIndex: Int = 0 141 | ) { 142 | self.id = id 143 | self.alignment = alignment 144 | self.offset = offset 145 | self.contentInset = contentInset 146 | self.zIndex = zIndex 147 | } 148 | 149 | public var kind: String { 150 | id.kind 151 | } 152 | 153 | public func hash(into hasher: inout Hasher) { 154 | hasher.combine(id) 155 | } 156 | 157 | /// The `UICollectionViewLayout` should include a header 158 | public static let header = CollectionViewSupplementaryView.header() 159 | 160 | /// The `UICollectionViewLayout` should include a header 161 | public static func header( 162 | offset: CGPoint = .zero, 163 | contentInset: EdgeInsets = .zero, 164 | zIndex: Int = 2 165 | ) -> CollectionViewSupplementaryView { 166 | CollectionViewSupplementaryView( 167 | id: .header, 168 | alignment: .topLeading, 169 | offset: offset, 170 | contentInset: contentInset, 171 | zIndex: zIndex 172 | ) 173 | } 174 | 175 | /// The `UICollectionViewLayout` should include a footer 176 | public static let footer = CollectionViewSupplementaryView.footer() 177 | 178 | /// The `UICollectionViewLayout` should include a footer 179 | public static func footer( 180 | offset: CGPoint = .zero, 181 | contentInset: EdgeInsets = .zero, 182 | zIndex: Int = 1 183 | ) -> CollectionViewSupplementaryView { 184 | CollectionViewSupplementaryView( 185 | id: .footer, 186 | alignment: .bottomTrailing, 187 | offset: offset, 188 | contentInset: contentInset, 189 | zIndex: zIndex 190 | ) 191 | } 192 | 193 | /// The `UICollectionViewLayout` should include a custom kind 194 | public static func custom( 195 | _ id: String, 196 | alignment: Alignment, 197 | offset: CGPoint = .zero, 198 | contentInset: EdgeInsets = .zero, 199 | zIndex: Int = 0 200 | ) -> CollectionViewSupplementaryView { 201 | CollectionViewSupplementaryView( 202 | id: .custom(id), 203 | alignment: alignment, 204 | offset: offset, 205 | contentInset: contentInset, 206 | zIndex: zIndex 207 | ) 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/CollectionViewListLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | @available(iOS 14.0, *) 9 | @available(macOS, unavailable) 10 | @available(tvOS, unavailable) 11 | @available(watchOS, unavailable) 12 | @frozen 13 | public struct CollectionViewListLayout: CollectionViewLayout { 14 | 15 | #if os(iOS) 16 | public typealias UICollectionViewCellType = UICollectionViewListCell 17 | public typealias UICollectionViewSupplementaryViewType = UICollectionViewListCell 18 | #endif 19 | 20 | @frozen 21 | public enum Appearance { 22 | case plain 23 | case grouped 24 | case insetGrouped 25 | } 26 | 27 | public var appearance: Appearance 28 | public var showsSeparators: Bool 29 | 30 | @inlinable 31 | public init( 32 | appearance: Appearance, 33 | showsSeparators: Bool = false 34 | ) { 35 | self.appearance = appearance 36 | self.showsSeparators = showsSeparators 37 | } 38 | 39 | #if os(iOS) 40 | public func makeUICollectionViewLayout( 41 | context: Context, 42 | options: CollectionViewLayoutOptions 43 | ) -> UICollectionViewCompositionalLayout { 44 | var configuration = UICollectionLayoutListConfiguration(appearance: { 45 | switch appearance { 46 | case .plain: 47 | return .plain 48 | case .grouped: 49 | return .grouped 50 | case .insetGrouped: 51 | return .insetGrouped 52 | } 53 | }()) 54 | configuration.headerMode = options.supplementaryViews.contains(where: { $0.id == .header }) ? .supplementary : .none 55 | configuration.footerMode = options.supplementaryViews.contains(where: { $0.id == .footer }) ? .supplementary : .none 56 | configuration.showsSeparators = showsSeparators 57 | configuration.backgroundColor = .clear 58 | if #available(iOS 15.0, *) { 59 | configuration.headerTopPadding = 0 60 | } 61 | let layout = UICollectionViewCompositionalLayout.list(using: configuration) 62 | return layout 63 | } 64 | 65 | public func makeUICollectionView( 66 | context: Context, 67 | options: CollectionViewLayoutOptions 68 | ) -> UICollectionView { 69 | 70 | let layout = makeUICollectionViewLayout(context: context, options: options) 71 | let uiCollectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) 72 | uiCollectionView.clipsToBounds = false 73 | uiCollectionView.keyboardDismissMode = .interactive 74 | return uiCollectionView 75 | } 76 | 77 | public func updateUICollectionView( 78 | _ collectionView: UICollectionView, 79 | context: Context 80 | ) { } 81 | #endif 82 | } 83 | 84 | @available(iOS 14.0, *) 85 | @available(macOS, unavailable) 86 | @available(tvOS, unavailable) 87 | @available(watchOS, unavailable) 88 | extension CollectionViewLayout where Self == CollectionViewListLayout { 89 | 90 | public static var plain: CollectionViewListLayout { .init(appearance: .plain) } 91 | 92 | public static var grouped: CollectionViewListLayout { .init(appearance: .grouped) } 93 | 94 | public static var insetGrouped: CollectionViewListLayout { .init(appearance: .insetGrouped, showsSeparators: true) } 95 | } 96 | 97 | 98 | 99 | // MARK: - Previews 100 | 101 | #if os(iOS) 102 | @available(iOS 15.0, *) 103 | @available(macOS, unavailable) 104 | @available(tvOS, unavailable) 105 | @available(watchOS, unavailable) 106 | struct CollectionViewListLayout_Previews: PreviewProvider { 107 | static var previews: some View { 108 | VStack(spacing: 12) { 109 | CollectionView( 110 | .plain, 111 | sections: [[1, 2], [3]], 112 | id: \.self 113 | ) { id in 114 | CellView("Cell \(id)") 115 | } header: { _ in 116 | HeaderFooter() 117 | } footer: { _ in 118 | HeaderFooter() 119 | } 120 | 121 | CollectionView( 122 | .grouped, 123 | sections: [[1, 2], [3]], 124 | id: \.self 125 | ) { id in 126 | CellView("Cell \(id)") 127 | } header: { _ in 128 | HeaderFooter() 129 | } footer: { _ in 130 | HeaderFooter() 131 | } 132 | 133 | CollectionView( 134 | .insetGrouped, 135 | sections: [[1, 2], [3]], 136 | id: \.self 137 | ) { id in 138 | CellView("Cell \(id)") 139 | } header: { _ in 140 | HeaderFooter() 141 | } footer: { _ in 142 | HeaderFooter() 143 | } 144 | } 145 | } 146 | 147 | struct CellView: View { 148 | var text: String 149 | init(_ text: String) { 150 | self.text = text 151 | } 152 | 153 | var body: some View { 154 | Text(text) 155 | .frame(maxWidth: .infinity) 156 | .padding() 157 | .background(Color.red) 158 | } 159 | } 160 | 161 | struct HeaderFooter: View { 162 | var body: some View { 163 | Text("Header/Footer") 164 | .frame(maxWidth: .infinity) 165 | .background(Color.blue) 166 | } 167 | } 168 | } 169 | #endif 170 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/CollectionViewRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | #if os(iOS) 9 | 10 | @available(iOS 14.0, *) 11 | @MainActor @preconcurrency 12 | public protocol CollectionViewRepresentable: View { 13 | 14 | associatedtype Data: RandomAccessCollection where Data.Element: RandomAccessCollection, Data.Index: Hashable, Data.Element.Element: Equatable & Identifiable 15 | associatedtype Layout: CollectionViewLayout 16 | associatedtype Coordinator: CollectionViewCoordinator 17 | 18 | var data: Data { get } 19 | var layout: Layout { get } 20 | 21 | func updateCoordinator(_ coordinator: Coordinator) 22 | 23 | func makeCoordinator() -> Coordinator 24 | } 25 | 26 | @available(iOS 14.0, *) 27 | extension CollectionViewRepresentable where Body == _CollectionViewRepresentableBody { 28 | 29 | public var body: _CollectionViewRepresentableBody { 30 | _CollectionViewRepresentableBody(representable: self) 31 | } 32 | } 33 | 34 | @frozen 35 | @available(iOS 14.0, *) 36 | public struct _CollectionViewRepresentableBody: UIViewRepresentable { 37 | 38 | public typealias Coordinator = Representable.Coordinator 39 | public typealias UIViewType = Representable.Layout.UICollectionViewType 40 | 41 | var representable: Representable 42 | 43 | public func makeUIView(context: Context) -> UIViewType { 44 | context.coordinator.context = CollectionViewLayoutContext( 45 | environment: context.environment, 46 | transaction: context.transaction 47 | ) 48 | let uiView = representable.layout.makeUICollectionView( 49 | context: context.coordinator.context, 50 | options: context.coordinator.layoutOptions 51 | ) 52 | context.coordinator.configure(to: uiView) 53 | 54 | return uiView 55 | } 56 | 57 | public func updateUIView(_ uiView: UIViewType, context: Context) { 58 | context.coordinator.context = CollectionViewLayoutContext( 59 | environment: context.environment, 60 | transaction: context.transaction 61 | ) 62 | representable.updateCoordinator(context.coordinator) 63 | context.coordinator.update(layout: representable.layout) 64 | context.coordinator.update(data: representable.data) 65 | } 66 | 67 | public func makeCoordinator() -> Coordinator { 68 | representable.makeCoordinator() 69 | } 70 | 71 | public func _overrideSizeThatFits( 72 | _ size: inout CGSize, 73 | in proposedSize: _ProposedSize, 74 | uiView: UIViewType 75 | ) { 76 | size.width = max(size.width, 10) 77 | size.height = max(size.height, 10) 78 | } 79 | } 80 | 81 | #endif 82 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/FlowStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | /// A view that arranges its subviews along multiple horizontal lines. 9 | /// 10 | /// > Warning: Version 4+ is required for non-leading alignment 11 | @frozen 12 | public struct FlowStack: VersionedView { 13 | 14 | public var alignment: Alignment 15 | public var spacing: CGFloat? 16 | public var content: Content 17 | 18 | public init( 19 | alignment: Alignment = .center, 20 | spacing: CGFloat? = nil, 21 | @ViewBuilder content: () -> Content 22 | ) { 23 | self.alignment = alignment 24 | self.spacing = spacing 25 | self.content = content() 26 | } 27 | 28 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 29 | public var v4Body: some View { 30 | FlowStackLayout(alignment: alignment, spacing: spacing) { 31 | content 32 | } 33 | } 34 | 35 | public var v1Body: some View { 36 | let spacing = spacing ?? 8 37 | ZStack(alignment: alignment) { 38 | var width: CGFloat = 0 39 | var x: CGFloat = 0 40 | var y: CGFloat = 0 41 | 42 | Color.clear 43 | .frame(height: 0) 44 | .hidden() 45 | .alignmentGuide(alignment.horizontal) { d in 46 | width = d.width 47 | x = 0 48 | y = 0 49 | return 0 50 | } 51 | 52 | content 53 | .alignmentGuide(alignment.horizontal) { d in 54 | if x + d.width > width { 55 | x = 0 56 | y += d.height + spacing 57 | } 58 | 59 | let result = x 60 | x += d.width + spacing 61 | return -result 62 | } 63 | .alignmentGuide(alignment.vertical) { d in 64 | d.height - y 65 | } 66 | } 67 | } 68 | } 69 | 70 | /// A layout that arranges subviews along multiple horizontal lines. 71 | @frozen 72 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 73 | public struct FlowStackLayout: Layout { 74 | 75 | public var alignment: Alignment = .center 76 | public var spacing: CGFloat? 77 | 78 | public init( 79 | alignment: Alignment, 80 | spacing: CGFloat? = nil 81 | ) { 82 | self.alignment = alignment 83 | self.spacing = spacing 84 | } 85 | 86 | public func sizeThatFits( 87 | proposal: ProposedViewSize, 88 | subviews: Subviews, 89 | cache: inout Void 90 | ) -> CGSize { 91 | let layoutProposal = layoutProposal( 92 | subviews: subviews, 93 | spacing: spacing, 94 | proposal: proposal, 95 | alignment: alignment 96 | ) 97 | return layoutProposal.frames.union.size 98 | } 99 | 100 | public func placeSubviews( 101 | in bounds: CGRect, 102 | proposal: ProposedViewSize, 103 | subviews: Subviews, 104 | cache: inout Void 105 | ) { 106 | let layoutProposal = layoutProposal( 107 | subviews: subviews, 108 | spacing: spacing, 109 | proposal: proposal, 110 | alignment: alignment 111 | ) 112 | for (frame, subview) in zip(layoutProposal.frames, subviews) { 113 | subview.place( 114 | at: CGPoint( 115 | x: frame.origin.x + bounds.minX, 116 | y: frame.origin.y + bounds.minY 117 | ), 118 | proposal: .init(frame.size) 119 | ) 120 | } 121 | } 122 | 123 | private struct LayoutProposal { 124 | var frames: [CGRect] 125 | } 126 | private func layoutProposal( 127 | subviews: Subviews, 128 | spacing: CGFloat?, 129 | proposal: ProposedViewSize, 130 | alignment: Alignment 131 | ) -> LayoutProposal { 132 | 133 | var result: [CGRect] = [] 134 | var currentPosition: CGPoint = .zero 135 | var currentLine: [CGRect] = [] 136 | let maxWidth = proposal.replacingUnspecifiedDimensions().width 137 | 138 | func endLine(index: Subviews.Index) { 139 | let union = currentLine.union 140 | result.append(contentsOf: currentLine.map { rect in 141 | var copy = rect 142 | copy.origin.y += currentPosition.y - union.minY 143 | return copy 144 | }) 145 | 146 | currentPosition.x = 0 147 | currentPosition.y += union.height 148 | if index < subviews.endIndex { 149 | let spacing = spacing ?? subviews[index - 1].spacing.distance( 150 | to: subviews[index].spacing, 151 | along: .vertical 152 | ) 153 | currentPosition.y += spacing 154 | } 155 | currentLine.removeAll() 156 | } 157 | 158 | for index in subviews.indices { 159 | let dimension = subviews[index].dimensions(in: proposal) 160 | if index > 0 { 161 | let spacing = spacing ?? subviews[index - 1].spacing.distance( 162 | to: subviews[index].spacing, 163 | along: .horizontal 164 | ) 165 | currentPosition.x += spacing 166 | 167 | if currentPosition.x + dimension.width > maxWidth { 168 | endLine(index: index) 169 | } 170 | } 171 | 172 | currentLine.append( 173 | CGRect( 174 | x: currentPosition.x, 175 | y: -dimension[alignment.vertical], 176 | width: dimension.width, 177 | height: dimension.height 178 | ) 179 | ) 180 | currentPosition.x += dimension.width 181 | } 182 | endLine(index: subviews.endIndex) 183 | 184 | return LayoutProposal( 185 | frames: result 186 | ) 187 | } 188 | 189 | public static var layoutProperties: LayoutProperties { 190 | var properties = LayoutProperties() 191 | properties.stackOrientation = .vertical 192 | return properties 193 | } 194 | } 195 | 196 | extension Sequence where Element == CGRect { 197 | var union: CGRect { 198 | reduce(.null, { $0.union($1) }) 199 | } 200 | } 201 | 202 | // MARK: - Previews 203 | 204 | struct FlowStack_Previews: PreviewProvider { 205 | static var previews: some View { 206 | Preview() 207 | } 208 | 209 | struct Preview: View { 210 | @State var width: CGFloat = 350 211 | 212 | var body: some View { 213 | VStack { 214 | Text(width.rounded().description) 215 | #if os(iOS) || os(macOS) 216 | Slider(value: $width, in: 10...375) 217 | #endif 218 | 219 | FlowStack { 220 | ScrollView { 221 | VStack(alignment: .center, spacing: 24) { 222 | FlowStack { 223 | Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.") 224 | 225 | Divider() 226 | 227 | Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.") 228 | } 229 | 230 | 231 | FlowStack( 232 | alignment: Alignment(horizontal: .center, vertical: .firstTextBaseline) 233 | ) { 234 | let words = "elit sed vulputate mi sit amet mauris commodo quis imperdiet" 235 | ForEach(words.components(separatedBy: .whitespaces), id: \.self) { word in 236 | Text(word) 237 | .font(word.count.isMultiple(of: 2) ? .title : .body) 238 | } 239 | 240 | Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.") 241 | 242 | Text("Hello World") 243 | 244 | Divider() 245 | 246 | Text("Hello World") 247 | .font(.title) 248 | } 249 | 250 | FlowStack(alignment: .center) { 251 | ForEach(1..<5) { num in 252 | Text(String(num)) 253 | .frame(minWidth: 30, minHeight: 30) 254 | .background(Circle().fill(Color.red)) 255 | } 256 | } 257 | 258 | FlowStack(alignment: .leading) { 259 | ForEach(1..<18) { num in 260 | Text(String(num)) 261 | .frame(minWidth: 30, minHeight: 30) 262 | .background(Circle().fill(Color.red)) 263 | } 264 | } 265 | 266 | FlowStack(alignment: .center) { 267 | ForEach(1..<23) { num in 268 | Text(String(num)) 269 | .frame(minWidth: 30, minHeight: 30) 270 | .background(Circle().fill(Color.red)) 271 | } 272 | } 273 | 274 | FlowStack(alignment: .trailing) { 275 | ForEach(1..<16) { num in 276 | Text(String(num)) 277 | .frame(minWidth: 30, minHeight: 30) 278 | .background(Circle().fill(Color.red)) 279 | } 280 | } 281 | } 282 | .frame(width: width) 283 | } 284 | } 285 | } 286 | .padding() 287 | } 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/ForEach.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | 7 | extension ForEach { 8 | @_disfavoredOverload 9 | @inlinable 10 | public init<_Data: RandomAccessCollection>( 11 | _ data: _Data, 12 | @ViewBuilder content: @escaping (_Data.Index, _Data.Element) -> Content 13 | ) where Data == Array<(_Data.Index, _Data.Element)>, ID == _Data.Index, Content: View { 14 | let elements = Array(zip(data.indices, data)) 15 | self.init(elements, id: \.0) { index, element in 16 | content(index, element) 17 | } 18 | } 19 | 20 | @inlinable 21 | public init< 22 | _Data: RandomAccessCollection 23 | >( 24 | _ data: _Data, 25 | id: KeyPath<_Data.Element, ID>, 26 | @ViewBuilder content: @escaping (_Data.Index, _Data.Element) -> Content 27 | ) where Data == Array<(_Data.Index, _Data.Element)>, Content: View { 28 | let elements = Array(zip(data.indices, data)) 29 | let elementPath: KeyPath<(_Data.Index, _Data.Element), _Data.Element> = \.1 30 | self.init(elements, id: elementPath.appending(path: id)) { index, element in 31 | content(index, element) 32 | } 33 | } 34 | 35 | @inlinable 36 | public init< 37 | _Data: RandomAccessCollection 38 | >( 39 | _ data: _Data, 40 | @ViewBuilder content: @escaping (_Data.Index, _Data.Element) -> Content 41 | ) where Data == Array<(_Data.Index, _Data.Element)>, _Data.Element: Identifiable, ID == _Data.Element.ID, Content: View { 42 | let elements = Array(zip(data.indices, data)) 43 | self.init(elements, id: \.1.id) { index, element in 44 | content(index, element) 45 | } 46 | } 47 | } 48 | 49 | // MARK: - ForEach Previews 50 | 51 | struct ForEach_Previews: PreviewProvider { 52 | struct Model: Identifiable { 53 | var id = UUID().uuidString 54 | } 55 | static var previews: some View { 56 | VStack { 57 | ForEach([10, 20, 30]) { index, number in 58 | Text("\(index): \(number)") 59 | } 60 | 61 | ForEach([10, 20, 30], id: \.self) { index, number in 62 | Text("\(index): \(number)") 63 | } 64 | 65 | ForEach([Model()]) { index, model in 66 | Text("\(index): \(model.id)") 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/HVStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | /// A view that arranges its subviews in a vertical or horizontal line. 9 | @frozen 10 | public struct HVStack: VersionedView { 11 | 12 | public var axis: Axis 13 | public var alignment: Alignment 14 | public var spacing: CGFloat? 15 | public var content: Content 16 | 17 | public init( 18 | axis: Axis, 19 | alignment: Alignment = .center, 20 | spacing: CGFloat? = nil, 21 | @ViewBuilder content: () -> Content 22 | ) { 23 | self.axis = axis 24 | self.alignment = alignment 25 | self.spacing = spacing 26 | self.content = content() 27 | } 28 | 29 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 30 | public var v4Body: some View { 31 | LayoutAdapter { 32 | switch axis { 33 | case .vertical: 34 | _VStackLayout(alignment: alignment.horizontal, spacing: spacing) 35 | case .horizontal: 36 | _HStackLayout(alignment: alignment.vertical, spacing: spacing) 37 | } 38 | } content: { 39 | content 40 | } 41 | } 42 | 43 | public var v1Body: some View { 44 | switch axis { 45 | case .vertical: 46 | VStack(alignment: alignment.horizontal, spacing: spacing) { 47 | content 48 | } 49 | .transition(.identity) 50 | case .horizontal: 51 | HStack(alignment: alignment.vertical, spacing: spacing) { 52 | content 53 | } 54 | .transition(.identity) 55 | } 56 | } 57 | } 58 | 59 | // MARK: - Previews 60 | 61 | struct HVStack_Previews: PreviewProvider { 62 | struct Preview: View { 63 | @State var isVertical: Bool = true 64 | 65 | var body: some View { 66 | VStack { 67 | Toggle("isVertical", isOn: $isVertical) 68 | 69 | HVStack(axis: isVertical ? .vertical : .horizontal, spacing: 0) { 70 | Color.red.frame(width: 100, height: 30) 71 | 72 | Color.blue.frame(width: 100, height: 30) 73 | } 74 | } 75 | .animation(.default, value: isVertical) 76 | } 77 | } 78 | 79 | static var previews: some View { 80 | Preview() 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/LabeledView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | /// The style for ``LabeledView`` 9 | @available(iOS, introduced: 13.0, deprecated: 100000.0, message: "Please use the built in LabeledContentStyle with LabeledContent") 10 | @available(macOS, introduced: 10.15, deprecated: 100000.0, message: "Please use the built in LabeledContentStyle with LabeledContent") 11 | @available(tvOS, introduced: 13.0, deprecated: 100000.0, message: "Please use the built in LabeledContentStyle with LabeledContent") 12 | @available(watchOS, introduced: 6.0, deprecated: 100000.0, message: "Please use the built in LabeledContentStyle with LabeledContent") 13 | @available(visionOS, introduced: 1.0, deprecated: 100000.0, message: "Please use the built in LabeledContentStyle with LabeledContent") 14 | public protocol LabeledViewStyle: ViewStyle where Configuration == LabeledViewStyleConfiguration { 15 | } 16 | 17 | /// The configuration parameters for ``LabeledView`` 18 | @available(iOS, introduced: 13.0, deprecated: 100000.0, message: "Please use the built in LabeledContentStyle with LabeledContent") 19 | @available(macOS, introduced: 10.15, deprecated: 100000.0, message: "Please use the built in LabeledContentStyle with LabeledContent") 20 | @available(tvOS, introduced: 13.0, deprecated: 100000.0, message: "Please use the built in LabeledContentStyle with LabeledContent") 21 | @available(watchOS, introduced: 6.0, deprecated: 100000.0, message: "Please use the built in LabeledContentStyle with LabeledContent") 22 | @available(visionOS, introduced: 1.0, deprecated: 100000.0, message: "Please use the built in LabeledContentStyle with LabeledContent") 23 | @frozen 24 | public struct LabeledViewStyleConfiguration { 25 | /// A type-erased label of a ``LabeledView`` 26 | public struct Label: ViewAlias { } 27 | public var label: Label { .init() } 28 | 29 | /// A type-erased content of a ``LabeledView`` 30 | public struct Content: ViewAlias { } 31 | public var content: Content { .init() } 32 | } 33 | 34 | /// A backwards compatible port of `LabeledContent` 35 | @available(iOS, introduced: 13.0, deprecated: 100000.0, message: "Please use the built in LabeledContentStyle with LabeledContent") 36 | @available(macOS, introduced: 10.15, deprecated: 100000.0, message: "Please use the built in LabeledContentStyle with LabeledContent") 37 | @available(tvOS, introduced: 13.0, deprecated: 100000.0, message: "Please use the built in LabeledContentStyle with LabeledContent") 38 | @available(watchOS, introduced: 6.0, deprecated: 100000.0, message: "Please use the built in LabeledContentStyle with LabeledContent") 39 | @available(visionOS, introduced: 1.0, deprecated: 100000.0, message: "Please use the built in LabeledContentStyle with LabeledContent") 40 | public struct LabeledView: View { 41 | var label: Label 42 | var content: Content 43 | 44 | public init( 45 | @ViewBuilder content: () -> Content, 46 | @ViewBuilder label: () -> Label 47 | ) { 48 | self.label = label() 49 | self.content = content() 50 | } 51 | 52 | public var body: some View { 53 | LabeledViewBody( 54 | configuration: .init() 55 | ) 56 | .viewAlias(LabeledViewStyleConfiguration.Label.self) { label } 57 | .viewAlias(LabeledViewStyleConfiguration.Content.self) { content } 58 | } 59 | } 60 | 61 | extension LabeledView where 62 | Label == LabeledViewStyleConfiguration.Label, 63 | Content == LabeledViewStyleConfiguration.Content 64 | { 65 | public init(_ configuration: LabeledViewStyleConfiguration) { 66 | self.label = configuration.label 67 | self.content = configuration.content 68 | } 69 | } 70 | 71 | private struct LabeledViewBody: ViewStyledView { 72 | var configuration: LabeledViewStyleConfiguration 73 | 74 | static var defaultStyle: DefaultLabeledViewStyle { .automatic } 75 | } 76 | 77 | extension View { 78 | @available(iOS, introduced: 13.0, deprecated: 100000.0, message: "Please use the built in LabeledContentStyle with LabeledContent") 79 | @available(macOS, introduced: 10.15, deprecated: 100000.0, message: "Please use the built in LabeledContentStyle with LabeledContent") 80 | @available(tvOS, introduced: 13.0, deprecated: 100000.0, message: "Please use the built in LabeledContentStyle with LabeledContent") 81 | @available(watchOS, introduced: 6.0, deprecated: 100000.0, message: "Please use the built in LabeledContentStyle with LabeledContent") 82 | @available(visionOS, introduced: 1.0, deprecated: 100000.0, message: "Please use the built in LabeledContentStyle with LabeledContent") 83 | public func labeledViewStyle(_ style: Style) -> some View { 84 | styledViewStyle(LabeledViewBody.self, style: style) 85 | } 86 | } 87 | 88 | extension LabeledViewStyle where Self == DefaultLabeledViewStyle { 89 | public static var automatic: DefaultLabeledViewStyle { DefaultLabeledViewStyle() } 90 | } 91 | 92 | extension VerticalAlignment { 93 | private struct LabelAlignment: AlignmentID { 94 | static func defaultValue(in context: ViewDimensions) -> CGFloat { 95 | context[VerticalAlignment.firstTextBaseline] 96 | } 97 | } 98 | 99 | public static let label = VerticalAlignment(LabelAlignment.self) 100 | } 101 | 102 | public struct DefaultLabeledViewStyle: LabeledViewStyle { 103 | 104 | @Environment(\.labelsHidden) var labelsHidden 105 | 106 | public init() { } 107 | 108 | public func makeBody(configuration: LabeledViewStyleConfiguration) -> some View { 109 | HStack(alignment: .label) { 110 | if !labelsHidden { 111 | configuration.label 112 | } 113 | 114 | configuration.content 115 | .frame( 116 | maxWidth: labelsHidden ? nil : .infinity, 117 | alignment: .trailing 118 | ) 119 | } 120 | } 121 | } 122 | 123 | // MARK: - Previews 124 | 125 | struct CustomLabeledViewStyle: LabeledViewStyle { 126 | func makeBody(configuration: LabeledViewStyleConfiguration) -> some View { 127 | VStack(alignment: .leading) { 128 | configuration.label 129 | configuration.content 130 | } 131 | } 132 | } 133 | 134 | struct RedLabeledViewStyle: LabeledViewStyle { 135 | func makeBody(configuration: LabeledViewStyleConfiguration) -> some View { 136 | LabeledView { 137 | configuration.content 138 | } label: { 139 | configuration.label 140 | .background(Color.red) 141 | } 142 | } 143 | } 144 | 145 | struct LabeledViewStyle_Previews: PreviewProvider { 146 | static var previews: some View { 147 | VStack { 148 | LabeledView { 149 | Text("Content") 150 | } label: { 151 | Text("Label") 152 | } 153 | 154 | LabeledView { 155 | Text("Content") 156 | } label: { 157 | Text("Label") 158 | } 159 | .labelsHidden() 160 | 161 | LabeledView { 162 | Text("Content") 163 | .font(.largeTitle) 164 | } label: { 165 | Text("Label") 166 | } 167 | 168 | LabeledView { 169 | VStack(alignment: .leading, spacing: 1) { 170 | Color.red.frame(height: 30) 171 | 172 | Color.red.frame(height: 30) 173 | } 174 | .alignmentGuide(.label, value: .center) 175 | } label: { 176 | Text("Label") 177 | } 178 | 179 | LabeledView { 180 | Text("Content") 181 | } label: { 182 | Text("Label") 183 | } 184 | .labeledViewStyle(CustomLabeledViewStyle()) 185 | 186 | LabeledView { 187 | Text("Content") 188 | } label: { 189 | Text("Label") 190 | } 191 | .labeledViewStyle(RedLabeledViewStyle()) 192 | 193 | LabeledView { 194 | Text("Content") 195 | } label: { 196 | Text("Label") 197 | } 198 | .labeledViewStyle(CustomLabeledViewStyle()) 199 | .labeledViewStyle(RedLabeledViewStyle()) 200 | 201 | LabeledView { 202 | Text("Content") 203 | } label: { 204 | Text("Label") 205 | } 206 | .labeledViewStyle(RedLabeledViewStyle()) 207 | .labeledViewStyle(CustomLabeledViewStyle()) 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/MarqueeHStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) 9 | @frozen 10 | public struct MarqueeHStack: View { 11 | 12 | public var spacing: CGFloat 13 | public var speed: Double 14 | public var minimumInterval: Double? 15 | public var isScrollEnabled: Bool 16 | public var content: Content 17 | 18 | private var selection: Binding? 19 | @State private var startAt: Date 20 | 21 | public init( 22 | spacing: CGFloat? = nil, 23 | speed: Double = 1, 24 | minimumInterval: Double? = nil, 25 | delay: TimeInterval = 2, 26 | isScrollEnabled: Bool = true, 27 | @ViewBuilder content: () -> Content 28 | ) where Selection == Never { 29 | self.selection = nil 30 | self.spacing = spacing ?? 4 31 | self.speed = speed 32 | self.minimumInterval = minimumInterval 33 | self.isScrollEnabled = isScrollEnabled 34 | self.content = content() 35 | self._startAt = State(wrappedValue: Date.now.addingTimeInterval(delay)) 36 | } 37 | 38 | @available(tvOS, unavailable) 39 | public init( 40 | selection: Binding, 41 | spacing: CGFloat? = nil, 42 | speed: Double = 1, 43 | minimumInterval: Double? = nil, 44 | delay: TimeInterval = 2, 45 | isScrollEnabled: Bool = true, 46 | @ViewBuilder content: () -> Content 47 | ) { 48 | self.selection = selection 49 | self.spacing = spacing ?? 4 50 | self.speed = speed 51 | self.minimumInterval = minimumInterval 52 | self.isScrollEnabled = isScrollEnabled 53 | self.content = content() 54 | self._startAt = State(wrappedValue: Date.now.addingTimeInterval(delay)) 55 | } 56 | 57 | public var body: some View { 58 | ZStack { 59 | content 60 | } 61 | .frame(maxWidth: .infinity) 62 | .hidden() 63 | .overlay { 64 | VariadicViewAdapter { 65 | content 66 | } content: { source in 67 | TimelineView( 68 | .animation(minimumInterval: minimumInterval, paused: !isScrollEnabled) 69 | ) { ctx in 70 | MarqueeHStackBody( 71 | selection: selection, 72 | views: source.children, 73 | keyframe: max(0, ctx.date.timeIntervalSince(startAt)), 74 | speed: speed, 75 | spacing: spacing 76 | ) 77 | } 78 | } 79 | .accessibilityRepresentation { 80 | HStack(alignment: .center, spacing: spacing) { 81 | content 82 | .lineLimit(1) 83 | } 84 | } 85 | } 86 | } 87 | } 88 | 89 | private class MarqueeHStackNodesBox { 90 | var nodes: [MarqueeHStackNodeProxy] = [] 91 | } 92 | 93 | private struct MarqueeHStackNodeProxy { 94 | var id: AnyHashable 95 | var frame: CGRect 96 | } 97 | 98 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) 99 | private struct MarqueeHStackBody: View { 100 | 101 | var selection: Binding? 102 | var views: AnyVariadicView 103 | var keyframe: TimeInterval 104 | var speed: Double 105 | var spacing: CGFloat 106 | 107 | @State var box: MarqueeHStackNodesBox? 108 | 109 | init( 110 | selection: Binding? = nil, 111 | views: AnyVariadicView, 112 | keyframe: TimeInterval, 113 | speed: Double, 114 | spacing: CGFloat 115 | ) { 116 | self.selection = selection 117 | self.views = views 118 | self.keyframe = keyframe 119 | self.speed = speed 120 | self.spacing = spacing 121 | self._box = State(wrappedValue: selection != nil ? MarqueeHStackNodesBox() : nil) 122 | } 123 | 124 | var body: some View { 125 | Canvas(rendersAsynchronously: false) { ctx, size in 126 | draw(ctx: ctx, size: size) 127 | } symbols: { 128 | views 129 | } 130 | #if os(iOS) || os(watchOS) || os(macOS) 131 | .overlay { 132 | if let selection { 133 | Rectangle() 134 | .hidden() 135 | .contentShape(Rectangle()) 136 | .gesture( 137 | DragGesture(minimumDistance: 0, coordinateSpace: .local) 138 | .onChanged { value in 139 | guard let selected = box?.nodes.first(where: { $0.frame.contains(value.location) }), 140 | let id = selected.id as? Selection 141 | else { 142 | return 143 | } 144 | selection.wrappedValue = id 145 | } 146 | ) 147 | } 148 | } 149 | #endif 150 | } 151 | 152 | private struct ResolvedSymbol { 153 | var id: AnyHashable 154 | var symbol: GraphicsContext.ResolvedSymbol 155 | } 156 | 157 | private struct Node { 158 | var symbol: GraphicsContext.ResolvedSymbol 159 | var frame: CGRect 160 | 161 | init(symbol: GraphicsContext.ResolvedSymbol, origin: CGPoint) { 162 | self.symbol = symbol 163 | self.frame = CGRect( 164 | x: origin.x, 165 | y: origin.y - symbol.size.height / 2, 166 | width: symbol.size.width, 167 | height: symbol.size.height 168 | ) 169 | } 170 | } 171 | 172 | private func draw(ctx: GraphicsContext, size: CGSize) { 173 | let resolvedSymbols: [ResolvedSymbol] = views.compactMap { 174 | guard let symbol = ctx.resolveSymbol(id: $0.id) else { 175 | return nil 176 | } 177 | if let id = $0.id(as: Selection.self) { 178 | return ResolvedSymbol(id: id, symbol: symbol) 179 | } 180 | return ResolvedSymbol(id: $0.id, symbol: symbol) 181 | } 182 | guard !resolvedSymbols.isEmpty else { 183 | return 184 | } 185 | 186 | let requiredWidth = resolvedSymbols.map(\.symbol.size.width).reduce(0, +) + spacing * CGFloat(views.count - 1) 187 | let timestamp = keyframe * 40 * speed 188 | var dx = timestamp 189 | .truncatingRemainder( 190 | dividingBy: max(size.width, requiredWidth + spacing) 191 | ) 192 | dx = (dx * ctx.environment.displayScale).rounded() / ctx.environment.displayScale 193 | 194 | var origin = CGPoint( 195 | x: 0, 196 | y: (size.height / 2) 197 | ) 198 | 199 | if requiredWidth > size.width { 200 | if speed > 0 { 201 | origin.x -= dx 202 | } else if speed < 0 { 203 | origin.x -= dx 204 | } 205 | } 206 | 207 | var proxies = [MarqueeHStackNodeProxy]() 208 | 209 | for resolvedSymbol in resolvedSymbols { 210 | let node = Node(symbol: resolvedSymbol.symbol, origin: origin) 211 | ctx.draw(node.symbol, in: node.frame) 212 | origin.x += (node.frame.size.width + spacing) 213 | proxies.append(MarqueeHStackNodeProxy(id: resolvedSymbol.id, frame: node.frame)) 214 | } 215 | 216 | if speed < 0 { 217 | origin.x = -dx - (requiredWidth + spacing) 218 | } 219 | 220 | for resolvedSymbol in resolvedSymbols { 221 | let node = Node(symbol: resolvedSymbol.symbol, origin: origin) 222 | ctx.draw(node.symbol, in: node.frame) 223 | origin.x += (node.frame.size.width + spacing) 224 | proxies.append(MarqueeHStackNodeProxy(id: resolvedSymbol.id, frame: node.frame)) 225 | } 226 | 227 | box?.nodes = proxies 228 | } 229 | } 230 | 231 | // MARK: - Previews 232 | 233 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) 234 | struct MarqueeHStack_Previews: PreviewProvider { 235 | static var previews: some View { 236 | VStack { 237 | MarqueeHStack(speed: 2) { 238 | ForEach(0..<10, id: \.self) { index in 239 | Label { 240 | Text("Index: \(index)") 241 | } icon: { 242 | Image(systemName: "info") 243 | } 244 | .foregroundColor(.white) 245 | .padding(.vertical, 6) 246 | .padding(.horizontal, 8) 247 | .background { 248 | Capsule() 249 | .fill(.black) 250 | } 251 | } 252 | } 253 | 254 | MarqueeHStack(speed: -1) { 255 | ForEach(10..<20, id: \.self) { index in 256 | Label { 257 | Text("Index: \(index)") 258 | } icon: { 259 | Image(systemName: "info") 260 | } 261 | .foregroundColor(.white) 262 | .padding(.vertical, 6) 263 | .padding(.horizontal, 8) 264 | .background { 265 | Capsule() 266 | .fill(.black) 267 | } 268 | } 269 | } 270 | 271 | MarqueeHStack { 272 | ForEach(20..<30, id: \.self) { index in 273 | Label { 274 | Text("Index: \(index)") 275 | } icon: { 276 | Image(systemName: "info") 277 | } 278 | .foregroundColor(.white) 279 | .padding(.vertical, 6) 280 | .padding(.horizontal, 8) 281 | .background { 282 | Capsule() 283 | .fill(.black) 284 | } 285 | } 286 | } 287 | 288 | MarqueeHStack { 289 | ForEach(1..<30, id: \.self) { index in 290 | Label { 291 | Text("Index: \(index)") 292 | } icon: { 293 | Image(systemName: "info") 294 | } 295 | .foregroundColor(.white) 296 | .padding(.vertical, 6) 297 | .padding(.horizontal, 8) 298 | .background { 299 | Capsule() 300 | .fill(.black) 301 | } 302 | } 303 | } 304 | 305 | MarqueeHStack { 306 | ForEach(1..<100, id: \.self) { index in 307 | Label { 308 | Text("Index: \(index)") 309 | } icon: { 310 | Image(systemName: "info") 311 | } 312 | .foregroundColor(.white) 313 | .padding(.vertical, 6) 314 | .padding(.horizontal, 8) 315 | .background { 316 | Capsule() 317 | .fill(.black) 318 | } 319 | } 320 | } 321 | } 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/MarqueeText.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | 7 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) 8 | public struct MarqueeText: View { 9 | 10 | public var text: Text 11 | public var spacing: CGFloat 12 | public var speed: Double 13 | 14 | @State private var startAt: Date = .now 15 | 16 | @_disfavoredOverload 17 | public init( 18 | _ text: S, 19 | spacing: CGFloat = 8, 20 | speed: Double = 1 21 | ) { 22 | self.init( 23 | Text(text), 24 | spacing: spacing, 25 | speed: speed 26 | ) 27 | } 28 | 29 | public init( 30 | _ text: LocalizedStringKey, 31 | spacing: CGFloat = 8, 32 | speed: Double = 1 33 | ) { 34 | self.init( 35 | Text(text), 36 | spacing: spacing, 37 | speed: speed 38 | ) 39 | } 40 | 41 | public init( 42 | _ text: Text, 43 | spacing: CGFloat = 8, 44 | speed: Double = 1 45 | ) { 46 | self.text = text 47 | self.spacing = spacing 48 | self.speed = speed 49 | } 50 | 51 | public var body: some View { 52 | text 53 | .hidden() 54 | .overlay { 55 | TimelineView( 56 | .periodic(from: startAt, by: 1 / 60) 57 | ) { ctx in 58 | let keyframe = max(0, ctx.date.timeIntervalSince(startAt)) 59 | MarqueeTextBody( 60 | text: text, 61 | spacing: spacing, 62 | speed: speed, 63 | keyframe: keyframe 64 | ) 65 | } 66 | } 67 | .accessibilityRepresentation { 68 | text 69 | } 70 | .lineLimit(1) 71 | } 72 | } 73 | 74 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) 75 | private struct MarqueeTextBody: View { 76 | var text: Text 77 | var spacing: CGFloat 78 | var speed: Double 79 | var keyframe: Double 80 | 81 | var body: some View { 82 | Canvas(rendersAsynchronously: false) { ctx, size in 83 | let frame = CGRect( 84 | x: spacing, 85 | y: 0, 86 | width: size.width - 2 * spacing, 87 | height: size.height 88 | ) 89 | let resolvedText = ctx.resolve(text) 90 | let sizeThatFits = resolvedText.measure( 91 | in: CGSize(width: .infinity, height: frame.size.height) 92 | ) 93 | var rect = CGRect( 94 | origin: CGPoint( 95 | x: frame.origin.x, 96 | y: (frame.size.height - sizeThatFits.height) / 2 97 | ), 98 | size: sizeThatFits 99 | ) 100 | if sizeThatFits.width <= frame.size.width { 101 | ctx.draw(resolvedText, in: rect) 102 | } else { 103 | let timestamp = keyframe * 40 * speed 104 | let delayOffset: CGFloat = 100 105 | var dx = timestamp 106 | .truncatingRemainder( 107 | dividingBy: max(frame.size.width, sizeThatFits.width + spacing) + min(delayOffset, sizeThatFits.width) 108 | ) - delayOffset 109 | dx = (dx * ctx.environment.displayScale).rounded() / ctx.environment.displayScale 110 | 111 | rect.origin.x -= dx 112 | rect.origin.x = min(frame.origin.x, rect.origin.x) 113 | ctx.draw(resolvedText, in: rect) 114 | 115 | rect.origin.x = frame.origin.x + sizeThatFits.width + spacing - dx 116 | rect.origin.x = max(frame.origin.x, rect.origin.x) 117 | ctx.draw(text, in: rect) 118 | } 119 | } 120 | .mask { 121 | GeometryReader { proxy in 122 | let inset = spacing / proxy.size.width 123 | LinearGradient( 124 | stops: [ 125 | .init(color: .clear, location: 0), 126 | .init(color: .black, location: inset), 127 | .init(color: .black, location: 1 - inset), 128 | .init(color: .clear, location: 1), 129 | ], 130 | startPoint: .leading, 131 | endPoint: .trailing 132 | ) 133 | } 134 | } 135 | .padding(.horizontal, -spacing) 136 | } 137 | } 138 | 139 | // MARK: - Previews 140 | 141 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) 142 | struct MarqueeText_Previews: PreviewProvider { 143 | static var previews: some View { 144 | Preview() 145 | } 146 | 147 | struct Preview: View { 148 | @State var width: CGFloat = 185 149 | @State var id = 0 150 | 151 | var body: some View { 152 | VStack(spacing: 12) { 153 | Text(width.description) 154 | #if os(iOS) || os(macOS) 155 | Slider(value: $width, in: 100...300) 156 | #endif 157 | Button("Reset") { id += 1 } 158 | 159 | MarqueeText( 160 | "One Two Three Four Five" 161 | ) 162 | .frame(maxWidth: .infinity, alignment: .leading) 163 | 164 | MarqueeText( 165 | "One Two Three Four Five" 166 | ) 167 | .id(id) 168 | .frame(width: width / 4, alignment: .leading) 169 | .frame(maxWidth: .infinity, alignment: .leading) 170 | 171 | MarqueeText( 172 | "One Two Three Four Five" 173 | ) 174 | .id(id) 175 | .frame(width: width, alignment: .leading) 176 | .frame(maxWidth: .infinity, alignment: .leading) 177 | 178 | MarqueeText( 179 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit." 180 | ) 181 | .id(id) 182 | 183 | MarqueeText( 184 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." 185 | ) 186 | .id(id) 187 | } 188 | .padding(.horizontal, 24) 189 | } 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/PlatformViewRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | #if !os(watchOS) 9 | 10 | /// A protocol for defining a `NSViewRepresentable`/`UIViewRepresentable` 11 | /// that has a backwards compatible `sizeThatFits` 12 | @MainActor @preconcurrency 13 | public protocol PlatformViewRepresentable: DynamicProperty, PrimitiveView { 14 | 15 | #if os(macOS) 16 | associatedtype PlatformView: NSView 17 | #else 18 | associatedtype PlatformView: UIView 19 | #endif 20 | 21 | @MainActor @preconcurrency func makeView(context: Context) -> PlatformView 22 | @MainActor @preconcurrency func updateView(_ view: PlatformView, context: Context) 23 | @MainActor @preconcurrency func sizeThatFits(_ proposal: ProposedSize, view: PlatformView) -> CGSize? 24 | @MainActor @preconcurrency static func dismantleView(_ view: PlatformView, coordinator: Coordinator) 25 | 26 | associatedtype Coordinator = Void 27 | @MainActor @preconcurrency func makeCoordinator() -> Coordinator 28 | 29 | typealias Context = _PlatformViewRepresentableBody.Context 30 | } 31 | 32 | extension PlatformViewRepresentable { 33 | func sizeThatFits(_ proposal: ProposedSize, view: PlatformView) -> CGSize? { nil } 34 | static func dismantleView(_ view: PlatformView, coordinator: Coordinator) { } 35 | } 36 | 37 | extension PlatformViewRepresentable where Coordinator == Void { 38 | func makeCoordinator() -> Coordinator { () } 39 | } 40 | 41 | extension PlatformViewRepresentable { 42 | 43 | private var content: _PlatformViewRepresentableBody { 44 | _PlatformViewRepresentableBody(representable: self) 45 | } 46 | 47 | public static func makeView( 48 | view: _GraphValue, 49 | inputs: _ViewInputs 50 | ) -> _ViewOutputs { 51 | _PlatformViewRepresentableBody._makeView(view: view[\.content], inputs: inputs) 52 | } 53 | 54 | public static func makeViewList( 55 | view: _GraphValue, 56 | inputs: _ViewListInputs 57 | ) -> _ViewListOutputs { 58 | _PlatformViewRepresentableBody._makeViewList(view: view[\.content], inputs: inputs) 59 | } 60 | 61 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 62 | public static func viewListCount( 63 | inputs: _ViewListCountInputs 64 | ) -> Int? { 65 | _PlatformViewRepresentableBody._viewListCount(inputs: inputs) 66 | } 67 | } 68 | 69 | #if os(macOS) 70 | public struct _PlatformViewRepresentableBody< 71 | Representable: PlatformViewRepresentable 72 | >: NSViewRepresentable { 73 | 74 | var representable: Representable 75 | 76 | public func makeNSView( 77 | context: Context 78 | ) -> Representable.PlatformView { 79 | representable.makeView(context: context) 80 | } 81 | 82 | public func updateNSView( 83 | _ nsView: Representable.PlatformView, 84 | context: Context 85 | ) { 86 | representable.updateView(nsView, context: context) 87 | } 88 | 89 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, *) 90 | public func sizeThatFits( 91 | _ proposal: ProposedViewSize, 92 | nsView: Representable.PlatformView, 93 | context: Context 94 | ) -> CGSize? { 95 | representable.sizeThatFits(ProposedSize(proposal), view: nsView) 96 | } 97 | 98 | public func _overrideSizeThatFits( 99 | _ size: inout CGSize, 100 | in proposedSize: _ProposedSize, 101 | nsView: Representable.PlatformView 102 | ) { 103 | if #available(macOS 13.0, iOS 16.0, tvOS 16.0, *) { 104 | // Already handled 105 | } else if let sizeThatFits = representable.sizeThatFits(ProposedSize(proposedSize), view: nsView) { 106 | size = sizeThatFits 107 | } 108 | } 109 | 110 | public static func dismantleNSView( 111 | _ nsView: Representable.PlatformView, 112 | coordinator: Coordinator 113 | ) { 114 | Representable.dismantleView(nsView, coordinator: coordinator) 115 | } 116 | 117 | public func makeCoordinator() -> Representable.Coordinator { 118 | representable.makeCoordinator() 119 | } 120 | } 121 | #else 122 | public struct _PlatformViewRepresentableBody< 123 | Representable: PlatformViewRepresentable 124 | >: UIViewRepresentable { 125 | 126 | var representable: Representable 127 | 128 | public func makeUIView( 129 | context: Context 130 | ) -> Representable.PlatformView { 131 | representable.makeView(context: context) 132 | } 133 | 134 | public func updateUIView( 135 | _ uiView: Representable.PlatformView, 136 | context: Context 137 | ) { 138 | representable.updateView(uiView, context: context) 139 | } 140 | 141 | @available(iOS 16.0, tvOS 16.0, watchOS 9.0, *) 142 | public func sizeThatFits( 143 | _ proposal: ProposedViewSize, 144 | uiView: Representable.PlatformView, 145 | context: Context 146 | ) -> CGSize? { 147 | representable.sizeThatFits(ProposedSize(proposal), view: uiView) 148 | } 149 | 150 | public func _overrideSizeThatFits( 151 | _ size: inout CGSize, 152 | in proposedSize: _ProposedSize, 153 | uiView: Representable.PlatformView 154 | ) { 155 | if #available(iOS 16.0, tvOS 16.0, watchOS 9.0, *) { 156 | // Already handled 157 | } else if let sizeThatFits = representable.sizeThatFits(ProposedSize(proposedSize), view: uiView) { 158 | size = sizeThatFits 159 | } 160 | } 161 | 162 | public static func dismantleUIView( 163 | _ uiView: Representable.PlatformView, 164 | coordinator: Coordinator 165 | ) { 166 | Representable.dismantleView(uiView, coordinator: coordinator) 167 | } 168 | 169 | public func makeCoordinator() -> Representable.Coordinator { 170 | representable.makeCoordinator() 171 | } 172 | } 173 | #endif 174 | 175 | #endif // !os(watchOS) 176 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/ProposedSizeReader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | 7 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) 8 | public struct ProposedSizeReader: View { 9 | let content: (ProposedSize) -> Content 10 | 11 | @State var size: ProposedSize = .unspecified 12 | 13 | public init(@ViewBuilder content: @escaping (ProposedSize) -> Content) { 14 | self.content = content 15 | } 16 | 17 | public var body: some View { 18 | content(size) 19 | .modifier(ProposedSizeObserver(size: $size)) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/RadialStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | /// A view that arranges its subviews along a radial circumference. 9 | @frozen 10 | public struct RadialStack: VersionedView { 11 | 12 | public var radius: CGFloat? 13 | public var content: Content 14 | 15 | public init(radius: CGFloat? = nil, @ViewBuilder content: () -> Content) { 16 | self.radius = radius 17 | self.content = content() 18 | } 19 | 20 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 21 | public var v4Body: some View { 22 | RadialStackLayout(radius: radius) { 23 | content 24 | } 25 | } 26 | 27 | public var v1Body: some View { 28 | VariadicViewAdapter { 29 | content 30 | } content: { source in 31 | ZStack(alignment: .topLeading) { 32 | GeometryReader { proxy in 33 | let radius = (radius ?? min(proxy.size.width, proxy.size.height) / 2) / 1.5 34 | let angle = 2.0 / CGFloat(source.children.count) * .pi 35 | ForEachSubview(source) { index, subview in 36 | subview 37 | .position( 38 | x: proxy.size.width / 2 + cos(angle * CGFloat(index) - .pi / 2) * radius, 39 | y: proxy.size.height / 2 + sin(angle * CGFloat(index) - .pi / 2) * radius 40 | ) 41 | } 42 | } 43 | } 44 | .aspectRatio(1, contentMode: .fit) 45 | } 46 | } 47 | } 48 | 49 | /// A layout that arranges subviews along a radial circumference. 50 | @frozen 51 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 52 | public struct RadialStackLayout: Layout { 53 | 54 | public var radius: CGFloat? 55 | 56 | public init(radius: CGFloat?) { 57 | self.radius = radius 58 | } 59 | 60 | public func sizeThatFits( 61 | proposal: ProposedViewSize, 62 | subviews: Subviews, 63 | cache: inout Void 64 | ) -> CGSize { 65 | if let radius { 66 | let length = radius * 2 67 | return CGSize(width: length, height: length) 68 | } 69 | var size = CGSize.zero 70 | for subview in subviews { 71 | let sizeThatFits = subview.sizeThatFits(.unspecified) 72 | size.width = max(size.width, sizeThatFits.width) 73 | size.height = max(size.height, sizeThatFits.height) 74 | } 75 | let length = round(.pi * min(size.width, size.height)) 76 | return CGSize(width: length, height: length) 77 | } 78 | 79 | public func placeSubviews( 80 | in bounds: CGRect, 81 | proposal: ProposedViewSize, 82 | subviews: Subviews, 83 | cache: inout Void 84 | ) { 85 | let radius = radius ?? min(bounds.size.width, bounds.size.height) / 2 86 | let angle = 2.0 / CGFloat(subviews.count) * .pi 87 | 88 | for (index, subview) in subviews.enumerated() { 89 | let sizeThatFits = subview.sizeThatFits(.unspecified) 90 | var point = CGPoint(x: bounds.midX, y: bounds.midY) 91 | 92 | point.x += cos(angle * CGFloat(index) - .pi / 2) * (radius - sizeThatFits.width / 2) 93 | point.y += sin(angle * CGFloat(index) - .pi / 2) * (radius - sizeThatFits.height / 2) 94 | 95 | subview.place(at: point, anchor: .center, proposal: .unspecified) 96 | } 97 | } 98 | } 99 | 100 | // MARK: - Previews 101 | 102 | struct CircleLayout_Previews: PreviewProvider { 103 | static var previews: some View { 104 | VStack { 105 | RadialStack(radius: 40) { 106 | Group { 107 | Circle() 108 | .fill(Color.blue) 109 | 110 | Circle() 111 | .fill(Color.red) 112 | 113 | Circle() 114 | .fill(Color.yellow) 115 | } 116 | .frame(width: 40, height: 40) 117 | } 118 | .border(Color.black) 119 | 120 | RadialStack { 121 | Circle() 122 | .fill(Color.blue) 123 | .frame(width: 60, height: 60) 124 | 125 | Circle() 126 | .fill(Color.red) 127 | .frame(width: 20, height: 20) 128 | 129 | Circle() 130 | .fill(Color.yellow) 131 | .frame(width: 40, height: 40) 132 | } 133 | .border(Color.black) 134 | 135 | RadialStack { 136 | Group { 137 | Circle() 138 | .fill(Color.blue) 139 | 140 | Circle() 141 | .fill(Color.purple) 142 | 143 | Circle() 144 | .fill(Color.red) 145 | 146 | Circle() 147 | .fill(Color.orange) 148 | 149 | Circle() 150 | .fill(Color.yellow) 151 | 152 | Circle() 153 | .fill(Color.green) 154 | .frame(width: 75, height: 75) 155 | } 156 | .frame(width: 50, height: 50) 157 | } 158 | .border(Color.black) 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/ResultAdapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | /// A view maps a `Result` value to it's `SuccessContent` or `FailureContent`. 9 | @frozen 10 | public struct ResultAdapter< 11 | SuccessContent: View, 12 | FailureContent: View 13 | >: View { 14 | 15 | @usableFromInline 16 | var content: ConditionalContent 17 | 18 | @inlinable 19 | public init( 20 | _ value: Result, 21 | @ViewBuilder success : (Success) -> SuccessContent, 22 | @ViewBuilder failure: (Failure) -> FailureContent 23 | ) { 24 | switch value { 25 | case .success(let value): 26 | self.content = .init(success(value)) 27 | case .failure(let error): 28 | self.content = .init(failure(error)) 29 | } 30 | } 31 | 32 | @inlinable 33 | public init( 34 | _ value: Binding>, 35 | @ViewBuilder success: (Binding) -> SuccessContent, 36 | @ViewBuilder failure: (Binding) -> FailureContent 37 | ) { 38 | switch value.wrappedValue { 39 | case .success: 40 | let unwrapped = Binding(value[keyPath: \.success])! 41 | self.content = .init(success(unwrapped)) 42 | case .failure: 43 | let unwrapped = Binding(value[keyPath: \.failure])! 44 | self.content = .init(failure(unwrapped)) 45 | } 46 | } 47 | 48 | public var body: some View { 49 | content 50 | } 51 | } 52 | 53 | extension ResultAdapter where FailureContent == EmptyView { 54 | @inlinable 55 | public init( 56 | _ value: Result, 57 | @ViewBuilder success: (Success) -> SuccessContent 58 | ) { 59 | self.init(value, success: success, failure: { _ in EmptyView() }) 60 | } 61 | 62 | @inlinable 63 | public init( 64 | _ value: Binding>, 65 | @ViewBuilder success: (Binding) -> SuccessContent 66 | ) { 67 | self.init(value, success: success, failure: { _ in EmptyView() }) 68 | } 69 | } 70 | 71 | extension Result { 72 | 73 | @usableFromInline 74 | var success: Success? { 75 | get { try? get() } 76 | set { 77 | if let newValue { 78 | self = .success(newValue) 79 | } 80 | } 81 | } 82 | 83 | @usableFromInline 84 | var failure: Failure? { 85 | get { 86 | do { 87 | _ = try get() 88 | return nil 89 | } catch { 90 | return error 91 | } 92 | } 93 | set { 94 | if let newValue { 95 | self = .failure(newValue) 96 | } 97 | } 98 | } 99 | } 100 | 101 | // MARK: - Previews 102 | 103 | struct ResultAdapter_Previews: PreviewProvider { 104 | static var previews: some View { 105 | Preview() 106 | } 107 | 108 | struct Preview: View { 109 | @State var value: Result = .success(1) 110 | 111 | var body: some View { 112 | VStack { 113 | ResultAdapter($value) { value in 114 | VStack { 115 | Text(value.wrappedValue.description) 116 | 117 | Button { 118 | value.wrappedValue += 1 119 | } label: { 120 | Text("Increment") 121 | } 122 | } 123 | } failure: { _ in 124 | Text("Error") 125 | } 126 | } 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/WeightedHStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | /// A view that arranges its subviews in a horizontal line a width 9 | /// that is relative to its `LayoutWeightPriority`. 10 | /// 11 | /// By default, all subviews will be arranged with equal width. 12 | /// 13 | @frozen 14 | public struct WeightedHStack: VersionedView { 15 | 16 | public var alignment: VerticalAlignment 17 | public var spacing: CGFloat? 18 | public var content: Content 19 | 20 | public init( 21 | alignment: VerticalAlignment = .center, 22 | spacing: CGFloat? = nil, 23 | @ViewBuilder content: () -> Content 24 | ) { 25 | self.alignment = alignment 26 | self.spacing = spacing 27 | self.content = content() 28 | } 29 | 30 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 31 | public var v4Body: some View { 32 | WeightedHStackLayout(alignment: alignment, spacing: spacing) { 33 | content 34 | } 35 | } 36 | 37 | @ViewBuilder 38 | public var v1Body: some View { 39 | let spacing = spacing ?? 8 40 | HStack(alignment: alignment, spacing: spacing) { 41 | content 42 | } 43 | .hidden() 44 | .overlay( 45 | GeometryReader { proxy in 46 | VariadicViewAdapter { 47 | content 48 | } content: { source in 49 | HStack(alignment: alignment, spacing: spacing) { 50 | let children = source.children 51 | let availableWidth = (proxy.size.width - (CGFloat(children.count - 1) * spacing)) 52 | let weights = children.reduce(into: 0) { value, subview in 53 | value += max(0, min(subview.layoutWeightPriority, CGFloat(children.count))) 54 | } 55 | ForEach(children) { subview in 56 | let weight = max(0, min(subview.layoutWeightPriority, CGFloat(children.count))) 57 | let width = availableWidth * weight / weights 58 | subview.frame(width: width) 59 | } 60 | } 61 | } 62 | } 63 | ) 64 | } 65 | } 66 | 67 | /// A layout that arranges subviews in a horizontal line a width 68 | /// that is relative to its `LayoutWeightPriority`. 69 | /// 70 | /// By default, all subviews will be placed with equal width. 71 | /// 72 | @frozen 73 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 74 | public struct WeightedHStackLayout: Layout { 75 | 76 | public var alignment: VerticalAlignment 77 | public var spacing: CGFloat? 78 | 79 | public init(alignment: VerticalAlignment = .center, spacing: CGFloat? = nil) { 80 | self.alignment = alignment 81 | self.spacing = spacing 82 | } 83 | 84 | public func sizeThatFits( 85 | proposal: ProposedViewSize, 86 | subviews: Subviews, 87 | cache: inout Void 88 | ) -> CGSize { 89 | guard !subviews.isEmpty else { return .zero } 90 | 91 | let width = proposal.replacingUnspecifiedDimensions().width 92 | let spacing = spacing(subviews: subviews) 93 | let availableWidth = (width - spacing.reduce(0, +)) / CGFloat(subviews.count) 94 | 95 | var sizeThatFits: CGSize = subviews.reduce(into: .zero) { value, subview in 96 | let width = availableWidth * max(0, min(subview.layoutWeightPriority, Double(subviews.count))) 97 | let sizeThatFits = subview.sizeThatFits( 98 | ProposedViewSize(width: width, height: proposal.height) 99 | ) 100 | value.height = max(value.height, sizeThatFits.height) 101 | } 102 | sizeThatFits.width = width 103 | return sizeThatFits 104 | } 105 | 106 | public func placeSubviews( 107 | in bounds: CGRect, 108 | proposal: ProposedViewSize, 109 | subviews: Subviews, 110 | cache: inout Void 111 | ) { 112 | guard !subviews.isEmpty else { return } 113 | 114 | let weights = subviews.reduce(into: 0) { value, subview in 115 | value += max(0, min(subview.layoutWeightPriority, Double(subviews.count))) 116 | } 117 | let spacing = spacing(subviews: subviews) 118 | let availableWidth = bounds.width - spacing.reduce(0, +) 119 | 120 | let anchor: UnitPoint 121 | let y: CGFloat 122 | switch alignment { 123 | case .top: 124 | anchor = .top 125 | y = bounds.minY 126 | case .bottom: 127 | anchor = .bottom 128 | y = bounds.maxY 129 | default: 130 | anchor = .center 131 | y = bounds.midY 132 | } 133 | 134 | var x = bounds.minX 135 | for index in subviews.indices { 136 | let weight = min(subviews[index].layoutWeightPriority, Double(subviews.count)) 137 | let width = availableWidth * max(0, weight) / max(weights, 1) 138 | x += width / 2 139 | let subviewProposal = ProposedViewSize(width: width, height: proposal.height) 140 | let placementProposal = ProposedViewSize( 141 | width: width, 142 | height: subviews[index].sizeThatFits(subviewProposal).height 143 | ) 144 | if weight > 0 { 145 | subviews[index].place( 146 | at: CGPoint(x: x, y: y), 147 | anchor: anchor, 148 | proposal: placementProposal 149 | ) 150 | } else { 151 | subviews[index].place( 152 | at: CGPoint(x: x, y: y), 153 | anchor: anchor, 154 | proposal: .zero 155 | ) 156 | } 157 | x += width / 2 + spacing[index] 158 | } 159 | } 160 | 161 | private func spacing(subviews: Subviews) -> [CGFloat] { 162 | let spacing: [CGFloat] = { 163 | if let spacing = self.spacing { 164 | return Array(repeating: spacing, count: subviews.count - 1) + [0] 165 | } 166 | return subviews.indices.map { index in 167 | guard index < subviews.count - 1 else { return 0 } 168 | return subviews[index].spacing.distance( 169 | to: subviews[index + 1].spacing, 170 | along: .horizontal 171 | ) 172 | } 173 | }() 174 | return spacing.indices.map { index in 175 | let weight = subviews[index].layoutWeightPriority 176 | if weight <= 0 { 177 | return 0 178 | } 179 | let hasNextNonZeroWeight = subviews[(index + 1).. 0 181 | } 182 | if hasNextNonZeroWeight { 183 | return spacing[index] 184 | } 185 | return 0 186 | } 187 | } 188 | 189 | public static var layoutProperties: LayoutProperties { 190 | HStackLayout.layoutProperties 191 | } 192 | } 193 | 194 | // MARK: - Previews 195 | 196 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 197 | struct WeightedHStackLayout_Previews: PreviewProvider { 198 | static var previews: some View { 199 | VStack { 200 | WeightedHStack(spacing: 24) { 201 | Color.yellow 202 | .layoutWeight(0) 203 | 204 | Color.yellow 205 | .layoutWeight(0) 206 | 207 | Color.red 208 | 209 | Color.yellow 210 | .layoutWeight(0) 211 | 212 | Color.yellow 213 | .layoutWeight(0) 214 | 215 | Color.blue 216 | 217 | Color.yellow 218 | .layoutWeight(0) 219 | 220 | Color.yellow 221 | .layoutWeight(0) 222 | } 223 | 224 | WeightedHStack(spacing: 24) { 225 | Color.red 226 | 227 | Color.yellow 228 | .layoutWeight(.infinity) 229 | 230 | Color.blue 231 | } 232 | 233 | WeightedHStack(spacing: 24) { 234 | Color.red 235 | 236 | Color.yellow 237 | .layoutWeight(.infinity) 238 | 239 | Color.yellow 240 | .layoutWeight(.infinity) 241 | 242 | Color.blue 243 | } 244 | 245 | WeightedHStack(spacing: 24) { 246 | Color.red 247 | 248 | Color.yellow 249 | .layoutWeight(2) 250 | 251 | Color.blue 252 | } 253 | 254 | WeightedHStack(spacing: 24) { 255 | Color.red 256 | .layoutWeight(2) 257 | 258 | Color.yellow 259 | .layoutWeight(0.5) 260 | 261 | Color.blue 262 | } 263 | } 264 | .padding(24) 265 | 266 | HStack(spacing: 12) { 267 | Button { 268 | } label: { 269 | Text("Primary") 270 | .padding() 271 | .frame(maxWidth: .infinity) 272 | .background { 273 | Capsule() 274 | .fill(.tertiary) 275 | } 276 | } 277 | 278 | WeightedHStack(spacing: 12) { 279 | Button { 280 | } label: { 281 | Text("Action A") 282 | .padding() 283 | .background { 284 | Capsule() 285 | .fill(.tertiary) 286 | } 287 | } 288 | 289 | Button { 290 | } label: { 291 | Text("Action B") 292 | .padding() 293 | .background { 294 | Capsule() 295 | .fill(.tertiary) 296 | } 297 | } 298 | } 299 | } 300 | .lineLimit(1) 301 | .previewDisplayName("Buttons") 302 | 303 | VStack { 304 | HStack { 305 | ForEach(0..<3, id: \.self) { _ in 306 | Color.yellow 307 | .aspectRatio(1, contentMode: .fit) 308 | } 309 | } 310 | .background(Color.blue) 311 | 312 | WeightedHStack { 313 | ForEach(0..<3, id: \.self) { _ in 314 | Color.yellow 315 | .aspectRatio(1, contentMode: .fit) 316 | } 317 | } 318 | .background(Color.red) 319 | } 320 | .previewDisplayName("Aspect Ratio") 321 | 322 | WeightedHStack { 323 | Text("Line 1") 324 | 325 | Text("Line 2") 326 | .layoutWeight(2) 327 | 328 | Text("Line 3") 329 | } 330 | .background(Color.blue) 331 | .previewDisplayName("Text") 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/WeightedPriority.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | /// A view trait that defines the relative weight priority of a subview 9 | /// when within a ``WeightedVStack`` or a ``WeightedHStack``. 10 | public struct LayoutWeightPriority: TraitValueKey { 11 | public static let defaultValue: Double = 1 12 | } 13 | 14 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 15 | extension LayoutSubviews.Element { 16 | var layoutWeightPriority: Double { 17 | self[LayoutWeightPriority.self] 18 | } 19 | } 20 | 21 | extension AnyVariadicView.Subview { 22 | var layoutWeightPriority: Double { 23 | self[LayoutWeightPriority.self] 24 | } 25 | } 26 | 27 | extension View { 28 | @ViewBuilder 29 | public func layoutWeight(_ value: Double) -> some View { 30 | trait(LayoutWeightPriority.self, value) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/View/WeightedVStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | /// A view that arranges its subviews in a vertical line a height 9 | /// that is relative to its `LayoutWeightPriority`. 10 | /// 11 | /// By default, all subviews will be arranged with equal height. 12 | /// 13 | @frozen 14 | public struct WeightedVStack: VersionedView { 15 | 16 | public var alignment: HorizontalAlignment 17 | public var spacing: CGFloat? 18 | public var content: Content 19 | 20 | public init( 21 | alignment: HorizontalAlignment = .center, 22 | spacing: CGFloat? = nil, 23 | @ViewBuilder content: () -> Content 24 | ) { 25 | self.alignment = alignment 26 | self.spacing = spacing 27 | self.content = content() 28 | } 29 | 30 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 31 | public var v4Body: some View { 32 | WeightedVStackLayout(alignment: alignment, spacing: spacing) { 33 | content 34 | } 35 | } 36 | 37 | @ViewBuilder 38 | public var v1Body: some View { 39 | let spacing = spacing ?? 8 40 | VStack(alignment: alignment, spacing: spacing) { 41 | content 42 | } 43 | .hidden() 44 | .overlay( 45 | GeometryReader { proxy in 46 | VariadicViewAdapter { 47 | content 48 | } content: { source in 49 | VStack(alignment: alignment, spacing: spacing) { 50 | let children = source.children 51 | let availableHeight = (proxy.size.height - (CGFloat(children.count - 1) * spacing)) 52 | let weights = children.reduce(into: 0) { value, subview in 53 | value += max(0, min(subview.layoutWeightPriority, CGFloat(children.count))) 54 | } 55 | ForEach(children) { subview in 56 | let weight = max(0, min(subview.layoutWeightPriority, CGFloat(children.count))) 57 | let height = availableHeight * weight / weights 58 | subview.frame(height: height) 59 | } 60 | } 61 | } 62 | } 63 | ) 64 | } 65 | } 66 | 67 | /// A layout that arranges subviews in a vertical line a height 68 | /// that is relative to its `LayoutWeightPriority`. 69 | /// 70 | /// By default, all subviews will be placed with equal height. 71 | /// 72 | @frozen 73 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 74 | public struct WeightedVStackLayout: Layout { 75 | 76 | public var alignment: HorizontalAlignment 77 | public var spacing: CGFloat? 78 | 79 | public init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil) { 80 | self.alignment = alignment 81 | self.spacing = spacing 82 | } 83 | 84 | public func sizeThatFits( 85 | proposal: ProposedViewSize, 86 | subviews: Subviews, 87 | cache: inout Void 88 | ) -> CGSize { 89 | guard !subviews.isEmpty else { return .zero } 90 | 91 | let height = proposal.replacingUnspecifiedDimensions().height 92 | let spacing = spacing(subviews: subviews) 93 | let availableHeight = (height - spacing.reduce(0, +)) / CGFloat(subviews.count) 94 | 95 | var sizeThatFits: CGSize = subviews.reduce(into: .zero) { value, subview in 96 | let height = availableHeight * max(0, min(subview.layoutWeightPriority, Double(subviews.count))) 97 | let sizeThatFits = subview.sizeThatFits( 98 | ProposedViewSize(width: proposal.width, height: height) 99 | ) 100 | value.width = max(value.width, sizeThatFits.width) 101 | } 102 | sizeThatFits.height = height 103 | return sizeThatFits 104 | } 105 | 106 | public func placeSubviews( 107 | in bounds: CGRect, 108 | proposal: ProposedViewSize, 109 | subviews: Subviews, 110 | cache: inout Void 111 | ) { 112 | guard !subviews.isEmpty else { return } 113 | 114 | let weights = subviews.reduce(into: 0) { value, subview in 115 | value += max(0, min(subview.layoutWeightPriority, Double(subviews.count))) 116 | } 117 | let spacing = spacing(subviews: subviews) 118 | let availableHeight = bounds.height - spacing.reduce(0, +) 119 | 120 | let anchor: UnitPoint 121 | let x: CGFloat 122 | switch alignment { 123 | case .leading: 124 | anchor = .leading 125 | x = bounds.minX 126 | case .trailing: 127 | anchor = .trailing 128 | x = bounds.maxX 129 | default: 130 | anchor = .center 131 | x = bounds.midX 132 | } 133 | 134 | var y = bounds.minY 135 | for index in subviews.indices { 136 | let weight = min(subviews[index].layoutWeightPriority, Double(subviews.count)) 137 | let height = availableHeight * max(0, weight) / max(weights, 1) 138 | 139 | y += height / 2 140 | if weight > 0 { 141 | let subviewProposal = ProposedViewSize(width: proposal.width, height: height) 142 | let placementProposal = ProposedViewSize( 143 | width: subviews[index].sizeThatFits(subviewProposal).width, 144 | height: height 145 | ) 146 | subviews[index].place( 147 | at: CGPoint(x: x, y: y), 148 | anchor: anchor, 149 | proposal: placementProposal 150 | ) 151 | } else { 152 | subviews[index].place( 153 | at: CGPoint(x: x, y: y), 154 | anchor: anchor, 155 | proposal: .zero 156 | ) 157 | } 158 | y += height / 2 + spacing[index] 159 | } 160 | } 161 | 162 | private func spacing(subviews: Subviews) -> [CGFloat] { 163 | let spacing: [CGFloat] = { 164 | if let spacing = self.spacing { 165 | return Array(repeating: spacing, count: subviews.count - 1) + [0] 166 | } 167 | return subviews.indices.map { index in 168 | guard index < subviews.count - 1 else { return 0 } 169 | return subviews[index].spacing.distance( 170 | to: subviews[index + 1].spacing, 171 | along: .horizontal 172 | ) 173 | } 174 | }() 175 | return spacing.indices.map { index in 176 | let weight = subviews[index].layoutWeightPriority 177 | if weight <= 0 { 178 | return 0 179 | } 180 | let hasNextNonZeroWeight = subviews[(index + 1).. 0 182 | } 183 | if hasNextNonZeroWeight { 184 | return spacing[index] 185 | } 186 | return 0 187 | } 188 | } 189 | 190 | public static var layoutProperties: LayoutProperties { 191 | VStackLayout.layoutProperties 192 | } 193 | } 194 | 195 | // MARK: - Previews 196 | 197 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 198 | struct WeightedVStackLayout_Previews: PreviewProvider { 199 | static var previews: some View { 200 | HStack { 201 | WeightedVStack(spacing: 24) { 202 | Color.yellow 203 | .layoutWeight(0) 204 | 205 | Color.yellow 206 | .layoutWeight(0) 207 | 208 | Color.red 209 | 210 | Color.yellow 211 | .layoutWeight(0) 212 | 213 | Color.yellow 214 | .layoutWeight(0) 215 | 216 | Color.blue 217 | 218 | Color.yellow 219 | .layoutWeight(0) 220 | 221 | Color.yellow 222 | .layoutWeight(0) 223 | } 224 | 225 | WeightedVStack(spacing: 24) { 226 | Color.red 227 | 228 | Color.yellow 229 | .layoutWeight(.infinity) 230 | 231 | Color.blue 232 | } 233 | 234 | WeightedVStack(spacing: 24) { 235 | Color.red 236 | 237 | Color.yellow 238 | .layoutWeight(.infinity) 239 | 240 | Color.yellow 241 | .layoutWeight(.infinity) 242 | 243 | Color.blue 244 | } 245 | 246 | WeightedVStack(spacing: 24) { 247 | Color.red 248 | 249 | Color.yellow 250 | .layoutWeight(2) 251 | 252 | Color.blue 253 | } 254 | 255 | WeightedVStack(spacing: 24) { 256 | Color.red 257 | .layoutWeight(2) 258 | 259 | Color.yellow 260 | .layoutWeight(0.5) 261 | 262 | Color.blue 263 | } 264 | } 265 | .padding(24) 266 | 267 | HStack { 268 | VStack { 269 | ForEach(0..<3, id: \.self) { _ in 270 | Color.yellow 271 | .aspectRatio(1, contentMode: .fit) 272 | } 273 | } 274 | .background(Color.blue) 275 | 276 | WeightedVStack { 277 | ForEach(0..<3, id: \.self) { _ in 278 | Color.yellow 279 | .aspectRatio(1, contentMode: .fit) 280 | } 281 | } 282 | .background(Color.red) 283 | } 284 | .previewDisplayName("Aspect Ratio") 285 | 286 | HStack { 287 | WeightedVStack(alignment: .leading) { 288 | Text("Line 1") 289 | 290 | Text("Line 2") 291 | 292 | Text("Line 3") 293 | } 294 | .background(Color.blue) 295 | 296 | VStack(alignment: .leading) { 297 | Text("Line 1") 298 | 299 | Text("Line 2") 300 | 301 | Text("Line 3") 302 | } 303 | .background(Color.blue) 304 | } 305 | .previewDisplayName("Text") 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/ViewModifier/Accessibility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | extension View { 9 | /// Disables accessibility elements from being generated, even when an assistive technology is running 10 | @inlinable 11 | public func accessibilityDisabled() -> some View { 12 | environment(\.accessibilityEnabled, false) 13 | } 14 | } 15 | 16 | @frozen 17 | public struct AccessibilityShowsLargeContentViewModifierIfAvailable: VersionedViewModifier { 18 | 19 | @inlinable 20 | public init() { } 21 | 22 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) 23 | public func v3Body(content: Content) -> some View { 24 | content 25 | .accessibilityShowsLargeContentViewer() 26 | } 27 | } 28 | 29 | @frozen 30 | public struct AccessibilityLargeContentViewModifierIfAvailable: VersionedViewModifier { 31 | 32 | @usableFromInline 33 | var label: Label 34 | 35 | @inlinable 36 | public init(@ViewBuilder label: () -> Label) { 37 | self.label = label() 38 | } 39 | 40 | @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) 41 | public func v3Body(content: Content) -> some View { 42 | content 43 | .accessibilityShowsLargeContentViewer { label } 44 | } 45 | } 46 | 47 | extension View { 48 | @inlinable 49 | public func accessibilityShowsLargeContentViewerIfAvailable() -> some View { 50 | modifier(AccessibilityShowsLargeContentViewModifierIfAvailable()) 51 | } 52 | 53 | @inlinable 54 | public func accessibilityLargeContentViewerIfAvailable( 55 | @ViewBuilder label: () -> Label 56 | ) -> some View { 57 | modifier(AccessibilityLargeContentViewModifierIfAvailable(label: label)) 58 | } 59 | } 60 | 61 | extension View { 62 | /// Optionally uses the specified string to identify the view. 63 | /// 64 | /// Use this value for testing. It isn't visible to the user. 65 | @inlinable 66 | public func accessibilityIdentifier(_ identifier: String?) -> ModifiedContent { 67 | guard let identifier = identifier else { 68 | return accessibility(addTraits: []) 69 | } 70 | return accessibility(identifier: identifier) 71 | } 72 | 73 | /// Optionally adds a label to the view that describes its contents. 74 | /// 75 | /// Use this method to provide an accessibility label for a view that doesn't display text, like an icon. 76 | /// For example, you could use this method to label a button that plays music with the text "Play". 77 | /// Don't include text in the label that repeats information that users already have. For example, 78 | /// don't use the label "Play button" because a button already has a trait that identifies it as a button. 79 | @_disfavoredOverload 80 | @inlinable 81 | public func accessibilityLabel(_ label: S?) -> ModifiedContent { 82 | guard let label = label else { 83 | return accessibility(addTraits: []) 84 | } 85 | return accessibility(label: Text(label)) 86 | } 87 | 88 | /// Optionally adds a textual description of the value that the view contains. 89 | /// 90 | /// Use this method to describe the value represented by a view, but only if that's different than the 91 | /// view's label. For example, for a slider that you label as "Volume" using accessibility(label:), 92 | /// you can provide the current volume setting, like "60%", using accessibility(value:). 93 | @_disfavoredOverload 94 | @inlinable 95 | public func accessibilityValue(_ value: S?) -> ModifiedContent { 96 | guard let value = value else { 97 | return accessibility(addTraits: []) 98 | } 99 | return accessibility(value: Text(value)) 100 | } 101 | 102 | /// Optionally adds an accessibility action to the view. Actions allow assistive technologies, 103 | /// such as the VoiceOver, to interact with the view by invoking the action. 104 | /// 105 | /// For example, this is how a `.default` action to compose 106 | /// a new email could be added to a view. 107 | /// 108 | /// var body: some View { 109 | /// ContentView() 110 | /// .accessibilityAction { 111 | /// // Handle action 112 | /// } 113 | /// } 114 | /// 115 | @inlinable 116 | public func accessibilityAction(named name: LocalizedStringKey?, _ handler: @escaping () -> Void) -> ModifiedContent { 117 | guard let name = name else { 118 | return accessibility(addTraits: []) 119 | } 120 | return accessibilityAction(AccessibilityActionKind(named: Text(name)), handler) 121 | } 122 | 123 | /// Optionally adds an accessibility action to the view. Actions allow assistive technologies, 124 | /// such as the VoiceOver, to interact with the view by invoking the action. 125 | /// 126 | /// For example, this is how a `.default` action to compose 127 | /// a new email could be added to a view. 128 | /// 129 | /// var body: some View { 130 | /// ContentView() 131 | /// .accessibilityAction { 132 | /// // Handle action 133 | /// } 134 | /// } 135 | /// 136 | @_disfavoredOverload 137 | @inlinable 138 | public func accessibilityAction(named name: S?, _ handler: @escaping () -> Void) -> ModifiedContent { 139 | guard let name = name else { 140 | return accessibility(addTraits: []) 141 | } 142 | return accessibilityAction(AccessibilityActionKind(named: Text(name)), handler) 143 | } 144 | } 145 | 146 | extension ModifiedContent where Modifier == AccessibilityAttachmentModifier { 147 | /// Optionally uses the specified string to identify the view. 148 | /// 149 | /// Use this value for testing. It isn't visible to the user. 150 | @inlinable 151 | public func accessibilityIdentifier(_ identifier: String?) -> ModifiedContent { 152 | guard let identifier = identifier else { 153 | return accessibility(addTraits: []) 154 | } 155 | return accessibility(identifier: identifier) 156 | } 157 | 158 | /// Optionally adds a label to the view that describes its contents. 159 | /// 160 | /// Use this method to provide an accessibility label for a view that doesn't display text, like an icon. 161 | /// For example, you could use this method to label a button that plays music with the text "Play". 162 | /// Don't include text in the label that repeats information that users already have. For example, 163 | /// don't use the label "Play button" because a button already has a trait that identifies it as a button. 164 | @_disfavoredOverload 165 | @inlinable 166 | public func accessibilityLabel(_ label: S?) -> ModifiedContent { 167 | guard let label = label else { 168 | return accessibility(addTraits: []) 169 | } 170 | return accessibility(label: Text(label)) 171 | } 172 | 173 | /// Optionally adds a textual description of the value that the view contains. 174 | /// 175 | /// Use this method to describe the value represented by a view, but only if that's different than the 176 | /// view's label. For example, for a slider that you label as "Volume" using accessibility(label:), 177 | /// you can provide the current volume setting, like "60%", using accessibility(value:). 178 | @_disfavoredOverload 179 | @inlinable 180 | public func accessibilityValue(_ value: S?) -> ModifiedContent { 181 | guard let value = value else { 182 | return accessibility(addTraits: []) 183 | } 184 | return accessibility(value: Text(value)) 185 | } 186 | 187 | /// Optionally adds an accessibility action to the view. Actions allow assistive technologies, 188 | /// such as the VoiceOver, to interact with the view by invoking the action. 189 | /// 190 | /// For example, this is how a `.default` action to compose 191 | /// a new email could be added to a view. 192 | /// 193 | /// var body: some View { 194 | /// ContentView() 195 | /// .accessibilityAction { 196 | /// // Handle action 197 | /// } 198 | /// } 199 | /// 200 | @inlinable 201 | public func accessibilityAction(named name: LocalizedStringKey?, _ handler: @escaping () -> Void) -> ModifiedContent { 202 | guard let name = name else { 203 | return accessibility(addTraits: []) 204 | } 205 | return accessibilityAction(AccessibilityActionKind(named: Text(name)), handler) 206 | } 207 | 208 | /// Optionally adds an accessibility action to the view. Actions allow assistive technologies, 209 | /// such as the VoiceOver, to interact with the view by invoking the action. 210 | /// 211 | /// For example, this is how a `.default` action to compose 212 | /// a new email could be added to a view. 213 | /// 214 | /// var body: some View { 215 | /// ContentView() 216 | /// .accessibilityAction { 217 | /// // Handle action 218 | /// } 219 | /// } 220 | /// 221 | @_disfavoredOverload 222 | @inlinable 223 | public func accessibilityAction(named name: S?, _ handler: @escaping () -> Void) -> ModifiedContent { 224 | guard let name = name else { 225 | return accessibility(addTraits: []) 226 | } 227 | return accessibilityAction(AccessibilityActionKind(named: Text(name)), handler) 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/ViewModifier/AlignmentOffset.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | 7 | /// A modifier that scales the edge alignment guides by an offset 8 | @frozen 9 | public struct AlignmentGuideAdjustmentModifier: ViewModifier { 10 | 11 | public var anchor: UnitPoint 12 | public var offset: CGPoint 13 | 14 | @inlinable 15 | public init(anchor: UnitPoint, offset: CGPoint) { 16 | self.anchor = anchor 17 | self.offset = offset 18 | } 19 | 20 | public func body(content: Content) -> some View { 21 | content 22 | .alignmentGuide(.top) { $0[.top] + ($0.height * anchor.y) + offset.y } 23 | .alignmentGuide(.bottom) { $0[.bottom] - ($0.height * anchor.y) - offset.y } 24 | .alignmentGuide(.trailing) { $0[.trailing] - ($0.width * anchor.x) - offset.x } 25 | .alignmentGuide(.leading) { $0[.leading] + ($0.width * anchor.x) + offset.x } 26 | } 27 | } 28 | 29 | extension View { 30 | 31 | /// A modifier that scales the edge alignment guides by an offset 32 | @inlinable 33 | public func alignmentGuideAdjustment( 34 | anchor: UnitPoint 35 | ) -> some View { 36 | modifier(AlignmentGuideAdjustmentModifier(anchor: anchor, offset: .zero)) 37 | } 38 | 39 | /// A modifier that scales the edge alignment guides by an offset 40 | @inlinable 41 | public func alignmentGuideAdjustment( 42 | x: CGFloat, 43 | y: CGFloat 44 | ) -> some View { 45 | modifier(AlignmentGuideAdjustmentModifier(anchor: .zero, offset: CGPoint(x: x, y: y))) 46 | } 47 | 48 | /// A modifier that scales the edge alignment guides by an offset 49 | @inlinable 50 | public func alignmentGuideAdjustment( 51 | anchor: UnitPoint, 52 | x: CGFloat, 53 | y: CGFloat 54 | ) -> some View { 55 | modifier(AlignmentGuideAdjustmentModifier(anchor: anchor, offset: CGPoint(x: x, y: y))) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/ViewModifier/Badge.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | 7 | /// A modifier that adds a view as a badge 8 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) 9 | @frozen 10 | public struct BadgeModifier: ViewModifier { 11 | 12 | public var alignment: Alignment 13 | public var anchor: UnitPoint 14 | public var scale: CGPoint 15 | public var inset: CGFloat 16 | public var label: Label 17 | 18 | @inlinable 19 | public init( 20 | alignment: Alignment, 21 | anchor: UnitPoint = UnitPoint(x: 0.25, y: 0.25), 22 | scale: CGPoint, 23 | inset: CGFloat = 0, 24 | @ViewBuilder label: () -> Label 25 | ) { 26 | self.alignment = alignment 27 | self.anchor = anchor 28 | self.label = label() 29 | self.scale = scale 30 | self.inset = inset 31 | } 32 | 33 | @inlinable 34 | public init( 35 | alignment: Alignment, 36 | anchor: UnitPoint = UnitPoint(x: 0.25, y: 0.25), 37 | scale: CGFloat, 38 | inset: CGFloat = 0, 39 | @ViewBuilder label: () -> Label 40 | ) { 41 | self.init( 42 | alignment: alignment, 43 | anchor: anchor, 44 | scale: CGPoint(x: scale, y: scale), 45 | inset: inset, 46 | label: label 47 | ) 48 | } 49 | 50 | var badge: some View { 51 | label.alignmentGuideAdjustment(anchor: anchor) 52 | } 53 | 54 | public func body(content: Content) -> some View { 55 | content 56 | .invertedMask(alignment: alignment) { 57 | badge.modifier( 58 | BadgeMaskEffect(scale: scale, inset: inset) 59 | ) 60 | } 61 | .overlay(badge, alignment: alignment) 62 | } 63 | } 64 | 65 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) 66 | extension View { 67 | 68 | /// A modifier that adds a view as a badge 69 | @inlinable 70 | public func badge( 71 | alignment: Alignment = .topTrailing, 72 | anchor: UnitPoint = UnitPoint(x: 0.25, y: 0.25), 73 | scale: CGPoint, 74 | inset: CGFloat = 0, 75 | @ViewBuilder label: () -> Label 76 | ) -> some View { 77 | modifier( 78 | BadgeModifier( 79 | alignment: alignment, 80 | anchor: anchor, 81 | scale: scale, 82 | inset: inset, 83 | label: label 84 | ) 85 | ) 86 | } 87 | 88 | /// A modifier that adds a view as a badge 89 | @inlinable 90 | public func badge( 91 | alignment: Alignment = .topTrailing, 92 | anchor: UnitPoint = UnitPoint(x: 0.25, y: 0.25), 93 | scale: CGFloat = 1, 94 | inset: CGFloat = 0, 95 | @ViewBuilder label: () -> Label 96 | ) -> some View { 97 | badge( 98 | alignment: alignment, 99 | anchor: anchor, 100 | scale: CGPoint(x: scale, y: scale), 101 | inset: inset, 102 | label: label 103 | ) 104 | } 105 | } 106 | 107 | private struct BadgeMaskEffect: GeometryEffect { 108 | var scale: CGPoint 109 | var inset: CGFloat 110 | 111 | func effectValue(size: CGSize) -> ProjectionTransform { 112 | let dx = scale.x * (size.width + inset) / size.width 113 | let dy = scale.y * (size.height + inset) / size.height 114 | let x = size.width * (dx - 1) / 2 115 | let y = size.height * (dy - 1) / 2 116 | return ProjectionTransform( 117 | CGAffineTransform(translationX: -x, y: -y) 118 | .scaledBy(x: dx, y: dy) 119 | ) 120 | } 121 | } 122 | 123 | // MARK: - Previews 124 | 125 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) 126 | struct BadgeModifier_Previews: PreviewProvider { 127 | struct Badge: View { 128 | var body: some View { 129 | Capsule() 130 | .fill(Color.blue) 131 | .frame(width: 40, height: 20) 132 | } 133 | } 134 | 135 | static var previews: some View { 136 | VStack(spacing: 44) { 137 | Rectangle() 138 | .badge(alignment: .topLeading) { 139 | Badge() 140 | } 141 | .badge(alignment: .topTrailing) { 142 | Badge() 143 | } 144 | .badge(alignment: .bottomLeading) { 145 | Badge() 146 | } 147 | .badge(alignment: .bottomTrailing) { 148 | Badge() 149 | } 150 | .shadow(color: .black, radius: 50, x: 0, y: 0) 151 | .frame(width: 100, height: 100) 152 | 153 | HStack { 154 | Circle() 155 | .badge(alignment: .topLeading) { 156 | Badge() 157 | } 158 | 159 | Circle() 160 | .badge(alignment: .topTrailing) { 161 | Badge() 162 | } 163 | 164 | Circle() 165 | .badge(alignment: .bottomLeading) { 166 | Badge() 167 | } 168 | 169 | Circle() 170 | .badge(alignment: .bottomTrailing) { 171 | Badge() 172 | } 173 | } 174 | .padding(.horizontal) 175 | } 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/ViewModifier/BlurModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | 7 | extension AnyTransition { 8 | 9 | /// Returns a transition that blurs the view. 10 | public static let blur = AnyTransition.modifier( 11 | active: BlurModifier(radius: 5), 12 | identity: BlurModifier(radius: 0) 13 | ) 14 | 15 | /// Returns a transition that blurs the view. 16 | public static func blur(radius: CGFloat, opaque: Bool = false) -> AnyTransition { 17 | .modifier( 18 | active: BlurModifier(radius: radius, opaque: opaque), 19 | identity: BlurModifier(radius: 0, opaque: opaque) 20 | ) 21 | } 22 | } 23 | 24 | /// A modifier that blurs the content 25 | @frozen 26 | public struct BlurModifier: ViewModifier { 27 | 28 | public var radius: CGFloat 29 | public var opaque: Bool 30 | 31 | public init(radius: CGFloat, opaque: Bool = false) { 32 | self.radius = radius 33 | self.opaque = opaque 34 | } 35 | 36 | public func body(content: Content) -> some View { 37 | content 38 | .blur(radius: radius, opaque: opaque) 39 | } 40 | } 41 | 42 | // MARK: - Previews 43 | 44 | struct BlurModifier_Previews: PreviewProvider { 45 | static var previews: some View { 46 | Preview() 47 | } 48 | 49 | struct Preview: View { 50 | @State var isHidden = false 51 | 52 | var body: some View { 53 | VStack { 54 | Button("Toggle") { 55 | withAnimation(.linear(duration: 1)) { 56 | isHidden.toggle() 57 | } 58 | } 59 | 60 | HStack(spacing: 24) { 61 | if !isHidden { 62 | Circle() 63 | .fill(Color.blue) 64 | .frame(width: 50, height: 50) 65 | } 66 | 67 | if !isHidden { 68 | Circle() 69 | .fill(Color.blue) 70 | .frame(width: 50, height: 50) 71 | .transition(.blur.combined(with: .opacity)) 72 | } 73 | 74 | if !isHidden { 75 | Circle() 76 | .fill(Color.blue) 77 | .frame(width: 50, height: 50) 78 | .transition(.blur) 79 | } 80 | 81 | if !isHidden { 82 | Circle() 83 | .fill(Color.blue) 84 | .frame(width: 50, height: 50) 85 | .transition(.blur(radius: 20)) 86 | } 87 | 88 | if !isHidden { 89 | Circle() 90 | .fill(Color.blue) 91 | .frame(width: 50, height: 50) 92 | .transition(.blur(radius: 100)) 93 | } 94 | } 95 | .frame(maxHeight: .infinity) 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/ViewModifier/Hidden.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | 7 | /// A modifier that conditionally hides a view 8 | @frozen 9 | public struct HiddenModifier: ViewModifier { 10 | 11 | public var isHidden: Bool 12 | public var transition: AnyTransition 13 | 14 | @inlinable 15 | public init( 16 | isHidden: Bool, 17 | transition: AnyTransition = .opacity 18 | ) { 19 | self.isHidden = isHidden 20 | self.transition = transition 21 | } 22 | 23 | public func body(content: Content) -> some View { 24 | if isHidden { 25 | content 26 | .hidden() 27 | } else { 28 | content 29 | .transition(transition) 30 | } 31 | } 32 | } 33 | 34 | extension View { 35 | 36 | /// A modifier that conditionally hides a view 37 | @inlinable 38 | public func hidden( 39 | _ isHidden: Bool, 40 | transition: AnyTransition = .identity 41 | ) -> some View { 42 | modifier( 43 | HiddenModifier( 44 | isHidden: isHidden, 45 | transition: transition 46 | ) 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/ViewModifier/Mask.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | 7 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) 8 | extension View { 9 | 10 | /// Masks this view using the inverted alpha channel of the given view. 11 | @inlinable 12 | public func invertedMask( 13 | alignment: Alignment = .center, 14 | @ViewBuilder mask: () -> Mask 15 | ) -> some View { 16 | self.mask( 17 | Rectangle() 18 | .scale(100) 19 | .ignoresSafeArea() 20 | .overlay( 21 | mask() 22 | .blendMode(.destinationOut), 23 | alignment: alignment 24 | ) 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/ViewModifier/OnAppearAndChange.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) 9 | @frozen 10 | public struct OnAppearAndChangeModifier< 11 | Value: Equatable 12 | >: VersionedViewModifier { 13 | 14 | public var value: Value 15 | public var action: (Value) -> Void 16 | 17 | @inlinable 18 | public init(value: Value, action: @escaping (Value) -> Void) { 19 | self.value = value 20 | self.action = action 21 | } 22 | 23 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) 24 | public func v5Body(content: Content) -> some View { 25 | content 26 | .onChange(of: value, initial: true) { _, newValue in 27 | action(newValue) 28 | } 29 | } 30 | 31 | #if !os(visionOS) 32 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) 33 | public func v2Body(content: Content) -> some View { 34 | content 35 | .onAppear { action(value) } 36 | .onChange(of: value, perform: action) 37 | } 38 | #endif 39 | } 40 | 41 | extension View { 42 | 43 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) 44 | public func onAppearAndChange( 45 | of value: V, 46 | perform action: @escaping (V) -> Void 47 | ) -> some View { 48 | modifier(OnAppearAndChangeModifier(value: value, action: action)) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/ViewModifier/ProposedSizeObserver.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | 7 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) 8 | public struct ProposedSizeObserver: ViewModifier { 9 | @Binding var size: ProposedSize 10 | 11 | public init(size: Binding) { 12 | self._size = size 13 | } 14 | 15 | public func body(content: Content) -> some View { 16 | content 17 | .background( 18 | GeometryReader { proxy in 19 | Color.clear 20 | .hidden() 21 | .onAppearAndChange(of: proxy.size) { size = ProposedSize(size: $0) } 22 | .onDisappear { size = .unspecified } 23 | } 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/ViewModifier/RotationRelativeFrameModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | /// A view modifier that arranges its subviews that transforms a subviews size to account for a rotation angle. 9 | @frozen 10 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 11 | public struct RotationRelativeFrameModifier: ViewModifier { 12 | 13 | public var rotation: Angle 14 | 15 | @inlinable 16 | public init(rotation: Angle) { 17 | self.rotation = rotation 18 | } 19 | 20 | public func body(content: Content) -> some View { 21 | RotationRelativeFrameLayout(rotation: rotation) { 22 | content 23 | .rotationEffect(rotation, anchor: .center) 24 | } 25 | } 26 | } 27 | 28 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 29 | extension View { 30 | 31 | /// A view modifier that arranges its subviews that transforms a subviews size to account for a rotation angle. 32 | @inlinable 33 | public func rotationRelativeFrame(rotation: Angle) -> some View { 34 | modifier(RotationRelativeFrameModifier(rotation: rotation)) 35 | } 36 | } 37 | 38 | /// A layout that transforms a subviews size to account for a rotation angle. 39 | @frozen 40 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 41 | public struct RotationRelativeFrameLayout: Layout { 42 | 43 | public var rotation: Angle 44 | 45 | @inlinable 46 | public init(rotation: Angle) { 47 | self.rotation = rotation 48 | } 49 | 50 | public func sizeThatFits( 51 | proposal: ProposedViewSize, 52 | subviews: Subviews, 53 | cache: inout () 54 | ) -> CGSize { 55 | var size = CGSize.zero 56 | for subview in subviews { 57 | let sizeThatFits = subview.sizeThatFits(proposal) 58 | size.width = max(size.width, sizeThatFits.width) 59 | size.height = max(size.height, sizeThatFits.height) 60 | } 61 | let rect = CGRect(origin: .zero, size: size) 62 | let transform = CGAffineTransform(rotationAngle: rotation.radians) 63 | return rect.applying(transform).size 64 | } 65 | 66 | public func placeSubviews( 67 | in bounds: CGRect, 68 | proposal: ProposedViewSize, 69 | subviews: Subviews, 70 | cache: inout () 71 | ) { 72 | for subview in subviews { 73 | subview.place( 74 | at: CGPoint(x: bounds.midX, y: bounds.midY), 75 | anchor: .center, 76 | proposal: proposal 77 | ) 78 | } 79 | } 80 | } 81 | 82 | // MARK: - Previews 83 | 84 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 85 | struct RotationRelativeFrameLayout_Previews: PreviewProvider { 86 | static var previews: some View { 87 | VStack { 88 | VStack { 89 | Image(systemName: "globe") 90 | 91 | Text("Hello, world!") 92 | } 93 | .border(Color.black) 94 | .padding() 95 | 96 | ZStack { 97 | Image(systemName: "globe") 98 | 99 | Text("Hello, world!") 100 | } 101 | .rotationRelativeFrame(rotation: .degrees(20)) 102 | .border(Color.black) 103 | .padding() 104 | 105 | VStack { 106 | Image(systemName: "globe") 107 | 108 | Text("Hello, world!") 109 | } 110 | .rotationRelativeFrame(rotation: .degrees(90)) 111 | .border(Color.black) 112 | .padding() 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/ViewModifier/SafeArea.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | 7 | /// A modifier that adds additional safe area padding 8 | /// to the edges of a view. 9 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) 10 | @frozen 11 | public struct SafeAreaPaddingModifier: ViewModifier { 12 | public var edgeInsets: EdgeInsets 13 | 14 | @inlinable 15 | public init(_ edgeInsets: EdgeInsets) { 16 | self.edgeInsets = edgeInsets 17 | } 18 | 19 | @inlinable 20 | public init(_ length: CGFloat = 16) { 21 | self.init(EdgeInsets(top: length, leading: length, bottom: length, trailing: length)) 22 | } 23 | 24 | @inlinable 25 | public init(_ edges: Edge.Set, _ length: CGFloat = 16) { 26 | let edgeInsets = EdgeInsets( 27 | top: edges.contains(.top) ? length : 0, 28 | leading: edges.contains(.leading) ? length : 0, 29 | bottom: edges.contains(.bottom) ? length : 0, 30 | trailing: edges.contains(.trailing) ? length : 0 31 | ) 32 | self.init(edgeInsets) 33 | } 34 | 35 | public func body(content: Content) -> some View { 36 | content 37 | ._safeAreaInsets(edgeInsets) 38 | } 39 | } 40 | 41 | @available(iOS, introduced: 14.0, deprecated: 100000.0, message: "Please use the built in safeAreaPadding modifier") 42 | @available(macOS, introduced: 11.0, deprecated: 100000.0, message: "Please use the built in safeAreaPadding modifier") 43 | @available(tvOS, introduced: 14.0, deprecated: 100000.0, message: "Please use the built in safeAreaPadding modifier") 44 | @available(watchOS, introduced: 7.0, deprecated: 100000.0, message: "Please use the built in safeAreaPadding modifier") 45 | extension View { 46 | 47 | /// A modifier that adds additional safe area padding 48 | /// to the edges of a view. 49 | @inlinable 50 | @_disfavoredOverload 51 | public func safeAreaPadding(_ edgeInsets: EdgeInsets) -> some View { 52 | modifier(SafeAreaPaddingModifier(edgeInsets)) 53 | } 54 | 55 | /// A modifier that adds additional safe area padding 56 | /// to the edges of a view. 57 | @inlinable 58 | @_disfavoredOverload 59 | public func safeAreaPadding(_ length: CGFloat = 16) -> some View { 60 | modifier(SafeAreaPaddingModifier(length)) 61 | } 62 | 63 | /// A modifier that adds additional safe area padding 64 | /// to the edges of a view. 65 | @inlinable 66 | @_disfavoredOverload 67 | public func safeAreaPadding(_ edges: Edge.Set, _ length: CGFloat = 16) -> some View { 68 | modifier(SafeAreaPaddingModifier(edges, length)) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/ViewModifier/ScaledFrame.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | 7 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) 8 | @frozen 9 | public struct ScaledFrameModifier: ViewModifier { 10 | 11 | public var width: CGFloat? 12 | public var height: CGFloat? 13 | public var alignment: Alignment 14 | @ScaledMetric var scale: CGFloat 15 | 16 | public init( 17 | width: CGFloat? = nil, 18 | height: CGFloat? = nil, 19 | alignment: Alignment, 20 | relativeTo textStyle: Font.TextStyle 21 | ) { 22 | self.width = width 23 | self.height = height 24 | self.alignment = alignment 25 | self._scale = ScaledMetric(wrappedValue: 1, relativeTo: textStyle) 26 | } 27 | 28 | public func body(content: Content) -> some View { 29 | content.frame( 30 | width: width.map { $0 * scale }, 31 | height: height.map { $0 * scale }, 32 | alignment: alignment 33 | ) 34 | } 35 | } 36 | 37 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) 38 | @frozen 39 | public struct ScaledFlexFrameModifier: ViewModifier { 40 | 41 | public var minWidth: CGFloat? 42 | public var maxWidth: CGFloat? 43 | public var minHeight: CGFloat? 44 | public var maxHeight: CGFloat? 45 | public var alignment: Alignment 46 | @ScaledMetric var scale: CGFloat 47 | 48 | public init( 49 | minWidth: CGFloat? = nil, 50 | maxWidth: CGFloat? = nil, 51 | minHeight: CGFloat? = nil, 52 | maxHeight: CGFloat? = nil, 53 | alignment: Alignment, 54 | relativeTo textStyle: Font.TextStyle 55 | ) { 56 | self.minWidth = minWidth 57 | self.maxWidth = maxWidth 58 | self.minHeight = minHeight 59 | self.maxHeight = maxHeight 60 | self.alignment = alignment 61 | self._scale = ScaledMetric(wrappedValue: 1, relativeTo: textStyle) 62 | } 63 | 64 | public func body(content: Content) -> some View { 65 | content.frame( 66 | minWidth: minWidth.map { $0 * scale }, 67 | maxWidth: maxWidth.map { $0 * scale }, 68 | minHeight: minHeight.map { $0 * scale }, 69 | maxHeight: maxHeight.map { $0 * scale }, 70 | alignment: alignment 71 | ) 72 | } 73 | } 74 | 75 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) 76 | extension View { 77 | 78 | @inlinable 79 | @_disfavoredOverload 80 | public func frame( 81 | _ size: CGFloat?, 82 | alignment: Alignment = .center, 83 | relativeTo textStyle: Font.TextStyle 84 | ) -> some View { 85 | frame(width: size, height: size, alignment: alignment, relativeTo: textStyle) 86 | } 87 | 88 | @inlinable 89 | @_disfavoredOverload 90 | public func frame( 91 | width: CGFloat? = nil, 92 | height: CGFloat? = nil, 93 | alignment: Alignment = .center, 94 | relativeTo textStyle: Font.TextStyle 95 | ) -> some View { 96 | modifier( 97 | ScaledFrameModifier( 98 | width: height, 99 | height: width, 100 | alignment: alignment, 101 | relativeTo: textStyle 102 | ) 103 | ) 104 | } 105 | 106 | @inlinable 107 | @_disfavoredOverload 108 | public func frame( 109 | minWidth: CGFloat? = nil, 110 | maxWidth: CGFloat? = nil, 111 | minHeight: CGFloat? = nil, 112 | maxHeight: CGFloat? = nil, 113 | alignment: Alignment = .center, 114 | relativeTo textStyle: Font.TextStyle 115 | ) -> some View { 116 | modifier( 117 | ScaledFlexFrameModifier( 118 | minWidth: minWidth, 119 | maxWidth: maxWidth, 120 | minHeight: minHeight, 121 | maxHeight: maxHeight, 122 | alignment: alignment, 123 | relativeTo: textStyle 124 | ) 125 | ) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/ViewModifier/Shimmer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Engine 7 | 8 | /// Redacts content and overlays a shimmering effect when `Value` is `nil` 9 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 8.0, *) 10 | @frozen 11 | public struct ShimmerAdapter: View { 12 | 13 | public var content: Content 14 | public var isActive: Bool 15 | public var animation: Animation? 16 | 17 | @inlinable 18 | public init( 19 | _ value: Optional, 20 | animation: Animation? = .linear(duration: 0.3), 21 | _ content: (Optional) -> Content 22 | ) { 23 | self.content = content(value) 24 | self.isActive = value == nil 25 | self.animation = animation 26 | } 27 | 28 | public var body: some View { 29 | content 30 | .shimmer(isActive: isActive, animation: animation) 31 | } 32 | } 33 | 34 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 8.0, *) 35 | extension View { 36 | /// Redacts content and overlays a shimmering effect 37 | public func shimmer( 38 | isActive: Bool = true, 39 | animation: Animation? = .linear(duration: 0.3) 40 | ) -> some View { 41 | modifier(ShimmerModifier(isActive: isActive, animation: animation)) 42 | } 43 | } 44 | 45 | /// A modifier that redacts content and overlays a shimmering effect. 46 | /// 47 | /// All active shimmer effects are synchronized to the same clock. 48 | /// 49 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 8.0, *) 50 | public struct ShimmerModifier: ViewModifier { 51 | var isActive: Bool 52 | var animation: Animation? 53 | 54 | enum ShimmerState { 55 | case inactive 56 | case transitioning(DispatchWorkItem?) 57 | case shimmering 58 | 59 | var completion: DispatchWorkItem? { 60 | if case .transitioning(let item) = self { 61 | return item 62 | } 63 | return nil 64 | } 65 | 66 | var isActive: Bool { 67 | switch self { 68 | case .inactive: 69 | return false 70 | case .transitioning, .shimmering: 71 | return true 72 | } 73 | } 74 | } 75 | 76 | @State var state: ShimmerState 77 | 78 | public init(isActive: Bool, animation: Animation? = .linear(duration: 0.3)) { 79 | self.isActive = isActive 80 | self.animation = animation 81 | self._state = State(initialValue: isActive ? .shimmering : .inactive) 82 | } 83 | 84 | public func body(content: Content) -> some View { 85 | content 86 | .transformEnvironment(\.self) { environment in 87 | if state.isActive { 88 | environment.isEnabled = false 89 | environment.redactionReasons.insert(.placeholder) 90 | } 91 | if isActive { 92 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 93 | environment.contentTransition = .identity 94 | } 95 | } 96 | } 97 | .mask( 98 | mask 99 | .animation(animation, value: state.isActive) 100 | ) 101 | .onChange(of: isActive) { [oldState = state] newValue in 102 | switch oldState { 103 | case .inactive: 104 | state = newValue ? .shimmering : .inactive 105 | 106 | case .transitioning(let completion): 107 | if newValue { 108 | completion?.cancel() 109 | state = .shimmering 110 | } 111 | case .shimmering: 112 | if !newValue { 113 | let duration = (animation?.duration(defaultDuration: 0.3) ?? 0) / 2 114 | if duration > 0 { 115 | let completion = DispatchWorkItem { 116 | var transaction = Transaction(animation: animation?.speed(2)) 117 | transaction.disablesAnimations = true 118 | withTransaction(transaction) { 119 | state = .inactive 120 | } 121 | } 122 | DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: completion) 123 | state = .transitioning(completion) 124 | } else { 125 | state = .inactive 126 | } 127 | } 128 | } 129 | } 130 | } 131 | 132 | @ViewBuilder 133 | private var mask: some View { 134 | switch state { 135 | case .inactive: 136 | Rectangle() 137 | .scale(1000) 138 | .ignoresSafeArea() 139 | case .transitioning, .shimmering: 140 | GradientMask() 141 | } 142 | } 143 | 144 | private struct GradientMask: View { 145 | @ObservedObject var clock = ShimmerClock.shared 146 | 147 | var body: some View { 148 | PhasedLinearGradient( 149 | phase: clock.phase 150 | ) 151 | .onAppear { clock.register() } 152 | .onDisappear { clock.unregister() } 153 | } 154 | 155 | struct PhasedLinearGradient: View { 156 | var phase: CGFloat 157 | 158 | var body: some View { 159 | LinearGradient( 160 | gradient: 161 | Gradient(stops: [ 162 | .init(color: Color.black.opacity(0.3), location: phase * 2 - 1), 163 | .init(color: Color.black, location: phase * 2 - 0.5), 164 | .init(color: Color.black.opacity(0.3), location: phase * 2) 165 | ]) 166 | , 167 | startPoint: .topLeading, 168 | endPoint: .bottomTrailing 169 | ) 170 | .animation(.linear(duration: 1 / 60), value: phase) 171 | } 172 | } 173 | } 174 | } 175 | 176 | private class ShimmerClock: ObservableObject { 177 | @Published var phase: CGFloat = 0 178 | 179 | private let duration: Double = 1.25 180 | 181 | private var registered: UInt = 0 182 | #if os(iOS) 183 | private var displayLink: CADisplayLink? 184 | #endif 185 | 186 | #if os(macOS) 187 | private var timer: Timer? 188 | #endif 189 | 190 | static let shared = ShimmerClock() 191 | private init() { 192 | } 193 | 194 | deinit { 195 | #if os(iOS) 196 | displayLink?.invalidate() 197 | #endif 198 | 199 | #if os(macOS) 200 | timer?.invalidate() 201 | #endif 202 | } 203 | 204 | #if os(iOS) 205 | @objc 206 | private func onClockTick(displayLink: CADisplayLink) { 207 | let offset = CGFloat((displayLink.targetTimestamp - displayLink.timestamp) / duration) 208 | onClockTick(offset: offset) 209 | } 210 | #endif 211 | 212 | #if os(macOS) 213 | @objc 214 | private func onClockTick(timer: Timer) { 215 | let offset = CGFloat(timer.timeInterval / duration) 216 | onClockTick(offset: offset) 217 | } 218 | #endif 219 | 220 | private func onClockTick(offset: CGFloat) { 221 | if phase >= 1 { 222 | phase = 0 223 | } else { 224 | phase = min(phase + offset, 1) 225 | } 226 | } 227 | 228 | func register() { 229 | if registered == 0 { 230 | registered += 1 231 | #if os(iOS) 232 | if let displayLink = displayLink { 233 | displayLink.isPaused = false 234 | } else { 235 | let displayLink = CADisplayLink( 236 | target: self, 237 | selector: #selector(onClockTick(displayLink:)) 238 | ) 239 | if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, *) { 240 | displayLink.preferredFrameRateRange = .init( 241 | minimum: 24, 242 | maximum: 60 243 | ) 244 | } 245 | displayLink.add(to: .current, forMode: .common) 246 | self.displayLink = displayLink 247 | } 248 | #endif 249 | 250 | #if os(macOS) 251 | if let timer = timer, timer.isValid { 252 | } else { 253 | let timer = Timer( 254 | fireAt: Date(), 255 | interval: 1 / 60, 256 | target: self, 257 | selector: #selector(onClockTick(timer:)), 258 | userInfo: nil, 259 | repeats: true 260 | ) 261 | RunLoop.current.add(timer, forMode: .common) 262 | self.timer = timer 263 | } 264 | #endif 265 | } else { 266 | registered += 1 267 | } 268 | } 269 | 270 | func unregister() { 271 | if registered == 1 { 272 | registered -= 1 273 | phase = 0 274 | #if os(iOS) 275 | displayLink?.isPaused = true 276 | #endif 277 | 278 | #if os(macOS) 279 | timer?.invalidate() 280 | #endif 281 | } else if registered > 1 { 282 | registered -= 1 283 | } 284 | } 285 | } 286 | 287 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 8.0, *) 288 | struct ShimmerModifier_Previews: PreviewProvider { 289 | struct Preview: View { 290 | @State var isActive = true 291 | 292 | var body: some View { 293 | VStack(alignment: .leading) { 294 | VStack(alignment: .leading) { 295 | Text(verbatim: isActive ? "Placeholder" : "Line 1, Line 2, Line 3") 296 | .border(Color.red) 297 | 298 | HStack { 299 | Text(verbatim: isActive ? "Placeholder" : "Line 1, Line 2, Line 3") 300 | .border(Color.red) 301 | .shimmer(isActive: isActive) 302 | 303 | Text("Trailing") 304 | } 305 | 306 | HStack { 307 | Text(verbatim: isActive ? "Placeholder" : "Line 1, Line 2, Line 3") 308 | .shimmer(isActive: isActive) 309 | 310 | Text("Trailing") 311 | } 312 | 313 | HStack { 314 | Text(verbatim: isActive ? "Placeholder" : "Line 1") 315 | .border(Color.red) 316 | 317 | Text("Trailing") 318 | } 319 | 320 | HStack { 321 | Text(verbatim: isActive ? "Placeholder" : "Line 1") 322 | .border(Color.red) 323 | .shimmer(isActive: isActive) 324 | 325 | Text("Trailing") 326 | } 327 | 328 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 329 | HStack { 330 | Text(verbatim: isActive ? "123" : "456") 331 | .shimmer(isActive: isActive) 332 | .contentTransition(.numericText()) 333 | 334 | Text("Trailing") 335 | } 336 | } 337 | } 338 | 339 | HStack { 340 | Text(isActive.description) 341 | 342 | Button { 343 | isActive.toggle() 344 | } label: { 345 | Text("Toggle") 346 | } 347 | .buttonStyle(.plain) 348 | 349 | Button { 350 | withAnimation { 351 | isActive.toggle() 352 | } 353 | } label: { 354 | Text("Toggle (Animated)") 355 | } 356 | .buttonStyle(.plain) 357 | } 358 | } 359 | .padding(.horizontal) 360 | .frame(maxWidth: .infinity, alignment: .leading) 361 | } 362 | } 363 | 364 | static var previews: some View { 365 | VStack { 366 | Preview() 367 | } 368 | } 369 | } 370 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/ViewModifier/SizeThatFitsRelativeFrameModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | 7 | /// A view modifier that transforms a views frame based on its size that fits 8 | @frozen 9 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 10 | public struct SizeThatFitsRelativeFrameModifier: ViewModifier { 11 | 12 | public var transform: (CGSize) -> CGSize 13 | 14 | @inlinable 15 | public init( 16 | transform: @escaping (CGSize) -> CGSize 17 | ) { 18 | self.transform = transform 19 | } 20 | 21 | public func body(content: Content) -> some View { 22 | SizeThatFitsRelativeFrameLayout(transform: transform) { 23 | content 24 | } 25 | } 26 | } 27 | 28 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 29 | extension View { 30 | 31 | /// A view modifier that transforms a views frame based on its size that fits 32 | @inlinable 33 | public func sizeThatFitsRelativeFrame( 34 | transform: @escaping (CGSize) -> CGSize 35 | ) -> some View { 36 | modifier(SizeThatFitsRelativeFrameModifier(transform: transform)) 37 | } 38 | } 39 | 40 | @frozen 41 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 42 | public struct SizeThatFitsRelativeFrameLayout: Layout { 43 | 44 | public var transform: (CGSize) -> CGSize 45 | 46 | @inlinable 47 | public init(transform: @escaping (CGSize) -> CGSize) { 48 | self.transform = transform 49 | } 50 | 51 | public func sizeThatFits( 52 | proposal: ProposedViewSize, 53 | subviews: Subviews, 54 | cache: inout () 55 | ) -> CGSize { 56 | var size = CGSize.zero 57 | for subview in subviews { 58 | let sizeThatFits = subview.sizeThatFits(proposal) 59 | size.width = max(size.width, sizeThatFits.width) 60 | size.height = max(size.height, sizeThatFits.height) 61 | } 62 | return transform(size) 63 | } 64 | 65 | public func placeSubviews( 66 | in bounds: CGRect, 67 | proposal: ProposedViewSize, 68 | subviews: Subviews, 69 | cache: inout () 70 | ) { 71 | for subview in subviews { 72 | subview.place( 73 | at: CGPoint(x: bounds.midX, y: bounds.midY), 74 | anchor: .center, 75 | proposal: proposal 76 | ) 77 | } 78 | } 79 | } 80 | 81 | /// A view modifier that transforms a views frame to the size of a shape 82 | @frozen 83 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 84 | public struct ShapeRelativeFrameModifier: ViewModifier { 85 | 86 | @frozen 87 | public enum Shape { 88 | case circle 89 | case capsule 90 | } 91 | public var shape: Shape 92 | 93 | @inlinable 94 | public init(shape: Shape) { 95 | self.shape = shape 96 | } 97 | 98 | public func body(content: Content) -> some View { 99 | content 100 | .sizeThatFitsRelativeFrame { size in 101 | switch shape { 102 | case .circle: 103 | let size = max(size.width, size.height) 104 | return CGSize(width: size, height: size) 105 | case .capsule: 106 | return CGSize( 107 | width: size.width + size.height, 108 | height: size.height 109 | ) 110 | } 111 | } 112 | } 113 | } 114 | 115 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 116 | extension View { 117 | 118 | /// A view modifier that transforms a views frame to the size of a shape 119 | @inlinable 120 | public func shapeRelativeFrame(_ shape: ShapeRelativeFrameModifier.Shape) -> some View { 121 | modifier(ShapeRelativeFrameModifier(shape: shape)) 122 | } 123 | } 124 | 125 | // MARK: - Previews 126 | 127 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 128 | struct SizeThatFitsRelativeFrameModifier_Previews: PreviewProvider { 129 | static var previews: some View { 130 | VStack { 131 | Text("Hello, World") 132 | .sizeThatFitsRelativeFrame { size in 133 | CGSize( 134 | width: size.width + size.height, 135 | height: size.height 136 | ) 137 | } 138 | .background(Capsule().fill(Color.blue)) 139 | 140 | Text("Hello, World") 141 | .padding(.vertical, 8) 142 | .shapeRelativeFrame(.capsule) 143 | .background(Capsule().fill(Color.blue)) 144 | 145 | Text("Hello, World") 146 | .padding(.horizontal, 8) 147 | .shapeRelativeFrame(.circle) 148 | .background(Circle().fill(Color.blue)) 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /Sources/Turbocharger/Sources/ViewModifier/VibrancyEffect.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Combine 7 | 8 | /// A modifier that overlays a Metal layer filter that intensifies the vibrancy 9 | @frozen 10 | @available(macOS, unavailable) 11 | @available(tvOS, unavailable) 12 | @available(watchOS, unavailable) 13 | public struct VibrancyEffectModifier: ViewModifier { 14 | 15 | @usableFromInline 16 | var intensity: Double? 17 | 18 | @inlinable 19 | public init(intensity: Double? = nil) { 20 | self.intensity = intensity 21 | } 22 | 23 | public func body(content: Content) -> some View { 24 | content 25 | .overlay( 26 | VibrancyEffectViewBody(intensity: intensity) 27 | ) 28 | } 29 | } 30 | 31 | /// A Metal layer filter that intensifies the vibrancy 32 | @frozen 33 | @available(macOS, unavailable) 34 | @available(tvOS, unavailable) 35 | @available(watchOS, unavailable) 36 | public struct VibrancyEffectView: View { 37 | 38 | @usableFromInline 39 | var intensity: Double? 40 | 41 | @inlinable 42 | public init(intensity: Double? = nil) { 43 | self.intensity = intensity 44 | } 45 | 46 | public var body: some View { 47 | VibrancyEffectViewBody(intensity: intensity) 48 | } 49 | } 50 | 51 | @available(macOS, unavailable) 52 | @available(tvOS, unavailable) 53 | @available(watchOS, unavailable) 54 | extension View { 55 | 56 | /// A modifier that overlays a Metal layer that intensifies the vibrancy 57 | @inlinable 58 | public func vibrant() -> some View { 59 | modifier(VibrancyEffectModifier(intensity: nil)) 60 | } 61 | 62 | /// A modifier that overlays a Metal layer that intensifies the vibrancy 63 | @inlinable 64 | public func vibrancy(intensity: Double? = nil) -> some View { 65 | modifier(VibrancyEffectModifier(intensity: intensity)) 66 | } 67 | } 68 | 69 | @available(macOS, unavailable) 70 | @available(tvOS, unavailable) 71 | @available(watchOS, unavailable) 72 | private struct VibrancyEffectViewBody: View { 73 | 74 | var intensity: Double? 75 | 76 | #if os(iOS) 77 | @State var brightness = UIScreen.main.brightness 78 | 79 | var body: some View { 80 | HDRLayerViewRepresentable() 81 | .blendMode(.multiply) 82 | .allowsHitTesting(false) 83 | .opacity((intensity ?? 1) * brightness) 84 | .onReceive( 85 | NotificationCenter.default.publisher(for: UIScreen.brightnessDidChangeNotification) 86 | ) { _ in 87 | brightness = UIScreen.main.brightness 88 | } 89 | } 90 | #else 91 | var body: Never { 92 | bodyError() 93 | } 94 | #endif 95 | } 96 | 97 | #if os(iOS) 98 | private struct HDRLayerViewRepresentable: UIViewRepresentable { 99 | func makeUIView(context: Context) -> HDRLayerView { 100 | let uiView = HDRLayerView() 101 | return uiView 102 | } 103 | 104 | func updateUIView(_ uiView: HDRLayerView, context: Context) { } 105 | } 106 | 107 | // Adapted from GlowGetter 108 | // 109 | // Copyright (c) 2025 Aether 110 | // https://github.com/Aeastr/GlowGetter 111 | // 112 | private class HDRLayerView: UIView { 113 | 114 | var commandQueue: MTLCommandQueue? 115 | var library: MTLLibrary? 116 | 117 | var metalLayer: CAMetalLayer { layer as! CAMetalLayer } 118 | override class var layerClass: AnyClass { CAMetalLayer.self } 119 | 120 | override init(frame: CGRect = .zero) { 121 | super.init(frame: frame) 122 | 123 | let device = MTLCreateSystemDefaultDevice() 124 | commandQueue = device?.makeCommandQueue() 125 | library = device?.makeDefaultLibrary() 126 | metalLayer.device = device 127 | 128 | // Enable HDR content 129 | metalLayer.setValue(NSNumber(booleanLiteral: true), forKey: "wantsExtendedDynamicRangeContent") 130 | 131 | metalLayer.pixelFormat = .bgr10a2Unorm 132 | if #available(iOS 13.4, *) { 133 | metalLayer.colorspace = CGColorSpace(name: CGColorSpace.displayP3_PQ) 134 | } else { 135 | metalLayer.colorspace = CGColorSpace(name: CGColorSpace.displayP3_HLG) 136 | } 137 | metalLayer.framebufferOnly = false 138 | metalLayer.backgroundColor = nil 139 | metalLayer.isOpaque = false 140 | 141 | // Change the blending mode to screen or add to brighten underlying content 142 | metalLayer.compositingFilter = "multiplyBlendMode" 143 | } 144 | 145 | required init?(coder: NSCoder) { 146 | fatalError("init(coder:) has not been implemented") 147 | } 148 | 149 | override func layoutSublayers(of layer: CALayer) { 150 | super.layoutSublayers(of: layer) 151 | CATransaction.begin() 152 | CATransaction.setDisableActions(true) 153 | render() 154 | CATransaction.commit() 155 | } 156 | 157 | override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { 158 | super.traitCollectionDidChange(previousTraitCollection) 159 | setNeedsDisplay() 160 | } 161 | 162 | private func render() { 163 | #if targetEnvironment(simulator) 164 | isHidden = true 165 | return 166 | #else 167 | let isHDRSupported = window?.screen.traitCollection.displayGamut == .P3 168 | isHidden = !isHDRSupported 169 | guard isHDRSupported else { return } 170 | 171 | guard let drawable = metalLayer.nextDrawable() else { return } 172 | 173 | // Create a texture to sample the underlying content if needed 174 | // This part would require more complex Metal code to sample the view beneath 175 | let descriptor = MTLRenderPassDescriptor() 176 | descriptor.colorAttachments[0].texture = drawable.texture 177 | descriptor.colorAttachments[0].loadAction = .clear 178 | // Use a much brighter color for HDR - these values can go beyond 1.0 for HDR 179 | // Using PQ color space, values like 3.0 or higher will appear very bright on HDR displays 180 | descriptor.colorAttachments[0].clearColor = MTLClearColorMake(1.0, 1.0, 1.0, 0.5) 181 | descriptor.colorAttachments[0].storeAction = .store 182 | 183 | guard 184 | let buffer = commandQueue?.makeCommandBuffer(), 185 | let encoder = buffer.makeRenderCommandEncoder(descriptor: descriptor) 186 | else { 187 | return 188 | } 189 | encoder.endEncoding() 190 | buffer.present(drawable) 191 | buffer.commit() 192 | #endif 193 | } 194 | } 195 | #endif 196 | 197 | // MARK: - Previews 198 | 199 | #if os(iOS) 200 | struct HDR_Previews: PreviewProvider { 201 | static var previews: some View { 202 | Preview() 203 | } 204 | 205 | struct Preview: View { 206 | @State var isExpanded = true 207 | 208 | struct ContentView: View { 209 | var body: some View { 210 | Color.blue 211 | .overlay( 212 | Text("Hello, World").foregroundColor(.white) 213 | ) 214 | } 215 | } 216 | 217 | var body: some View { 218 | VStack(spacing: 0) { 219 | Group { 220 | ContentView() 221 | 222 | ContentView() 223 | .vibrancy(intensity: 0.25) 224 | 225 | ContentView() 226 | .vibrancy(intensity: 0.5) 227 | 228 | ContentView() 229 | .vibrancy(intensity: 0.75) 230 | 231 | ContentView() 232 | .vibrant() 233 | 234 | ContentView() 235 | } 236 | .scaleEffect(isExpanded ? 1 : 0.5) 237 | .frame(height: isExpanded ? 100 : 80) 238 | } 239 | .onTapGesture { 240 | withAnimation { 241 | isExpanded.toggle() 242 | } 243 | } 244 | } 245 | } 246 | } 247 | #endif 248 | -------------------------------------------------------------------------------- /Sources/Turbocharger/module.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | @_exported import Engine 6 | --------------------------------------------------------------------------------