diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c8f6633 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,28 @@ +name: Build + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build-ios: + name: Build for iOS + runs-on: macos-15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Build CarPlayDemo for iOS Simulator + run: | + xcodebuild build \ + -project Xcode/CarPlayDemo.xcodeproj \ + -scheme CarPlayDemo \ + -destination 'generic/platform=iOS Simulator' diff --git a/Package.swift b/Package.swift index b667907..02ff4e9 100644 --- a/Package.swift +++ b/Package.swift @@ -1,9 +1,9 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.2 import PackageDescription let package = Package( name: "CarPlayUI", - platforms: [.iOS(.v13)], + platforms: [.iOS(.v17)], products: [ .library( name: "CarPlayUI", @@ -14,14 +14,18 @@ let package = Package( .target( name: "CarPlayUI", swiftSettings: [ - .swiftLanguageMode(.v6) + .swiftLanguageMode(.v6), + .enableUpcomingFeature("InferIsolatedConformances"), + .enableUpcomingFeature("NonisolatedNonsendingByDefault") ] ), .testTarget( name: "CarPlayUITests", dependencies: ["CarPlayUI"], swiftSettings: [ - .swiftLanguageMode(.v6) + .swiftLanguageMode(.v6), + .enableUpcomingFeature("InferIsolatedConformances"), + .enableUpcomingFeature("NonisolatedNonsendingByDefault") ] ) ] diff --git a/Sources/CarPlayUI/Extensions/CPGridButton.swift b/Sources/CarPlayUI/Extensions/CPGridButton.swift index 10b930b..581dbf8 100644 --- a/Sources/CarPlayUI/Extensions/CPGridButton.swift +++ b/Sources/CarPlayUI/Extensions/CPGridButton.swift @@ -10,6 +10,7 @@ import CarPlay internal extension CPGridButton { + @MainActor convenience init(label: ParentView, action: @escaping (CPGridButton) -> ()) { // extract labels var labels = label.children.compactMap { diff --git a/Sources/CarPlayUI/Extensions/CPPointOfInterest.swift b/Sources/CarPlayUI/Extensions/CPPointOfInterest.swift index 6a4a559..b05118d 100644 --- a/Sources/CarPlayUI/Extensions/CPPointOfInterest.swift +++ b/Sources/CarPlayUI/Extensions/CPPointOfInterest.swift @@ -11,6 +11,7 @@ import CarPlay @available(iOS 14.0, *) internal extension CPPointOfInterest { + @MainActor convenience init(_ view: ViewObject) { if #available(iOS 16.0, *) { self.init( diff --git a/Sources/CarPlayUI/Extensions/CPTextButton.swift b/Sources/CarPlayUI/Extensions/CPTextButton.swift index 13fc0cf..b8e6102 100644 --- a/Sources/CarPlayUI/Extensions/CPTextButton.swift +++ b/Sources/CarPlayUI/Extensions/CPTextButton.swift @@ -11,6 +11,7 @@ import CarPlay @available(iOS 14.0, *) internal extension CPTextButton { + @MainActor convenience init(button: Button) { let title = _TextProxy(button.label).rawText let textStyle = CPTextButtonStyle(role: button.role) diff --git a/Sources/CarPlayUI/Extensions/UIImage.swift b/Sources/CarPlayUI/Extensions/UIImage.swift index bc7d4a0..5cf43a2 100644 --- a/Sources/CarPlayUI/Extensions/UIImage.swift +++ b/Sources/CarPlayUI/Extensions/UIImage.swift @@ -11,6 +11,7 @@ import CarPlay internal extension UIImage { + @MainActor convenience init?( _ image: _ImageProxy, environment: EnvironmentValues = .defaultEnvironment @@ -38,6 +39,7 @@ internal extension UIImage { } } + @MainActor static func unsafe( _ image: Image, environment: EnvironmentValues = .defaultEnvironment, @@ -46,7 +48,8 @@ internal extension UIImage { ) -> UIImage { unsafe(_ImageProxy(image), environment: environment, file: file, line: line) } - + + @MainActor static func unsafe( _ image: _ImageProxy, environment: EnvironmentValues = .defaultEnvironment, diff --git a/Sources/CarPlayUI/TokamakCore/Animation/Animatable.swift b/Sources/CarPlayUI/TokamakCore/Animation/Animatable.swift index ca75dca..46a7d30 100644 --- a/Sources/CarPlayUI/TokamakCore/Animation/Animatable.swift +++ b/Sources/CarPlayUI/TokamakCore/Animation/Animatable.swift @@ -18,8 +18,8 @@ import Foundation public protocol Animatable { - associatedtype AnimatableData: VectorArithmetic - var animatableData: Self.AnimatableData { get set } + associatedtype _AnimatableData: VectorArithmetic + var animatableData: Self._AnimatableData { get set } } public protocol _PrimitiveAnimatable {} @@ -32,7 +32,7 @@ public extension Animatable where Self: VectorArithmetic { } } -public extension Animatable where Self.AnimatableData == EmptyAnimatableData { +public extension Animatable where Self._AnimatableData == EmptyAnimatableData { var animatableData: EmptyAnimatableData { @inlinable get { EmptyAnimatableData() } // swiftlint:disable:next unused_setter_value @@ -140,26 +140,34 @@ public struct AnimatablePair: VectorArithmetic } extension CGPoint: Animatable { - public var animatableData: AnimatablePair { + public typealias _AnimatableData = AnimatablePair + public var animatableData: _AnimatableData { @inlinable get { .init(x, y) } @inlinable set { (x, y) = newValue[] } } } extension CGSize: Animatable { - public var animatableData: AnimatablePair { + public typealias _AnimatableData = AnimatablePair + public var animatableData: _AnimatableData { @inlinable get { .init(width, height) } @inlinable set { (width, height) = newValue[] } } } extension CGRect: Animatable { - public var animatableData: AnimatablePair { + public typealias _AnimatableData = AnimatablePair, AnimatablePair> + public var animatableData: _AnimatableData { @inlinable get { - .init(origin.animatableData, size.animatableData) + .init( + AnimatablePair(origin.x, origin.y), + AnimatablePair(size.width, size.height) + ) } @inlinable set { - (origin.animatableData, size.animatableData) = newValue[] + let value = newValue[] + origin = .init(x: value.0[].0, y: value.0[].1) + size = .init(width: value.1[].0, height: value.1[].1) } } } diff --git a/Sources/CarPlayUI/TokamakCore/Animation/Animation.swift b/Sources/CarPlayUI/TokamakCore/Animation/Animation.swift index 589b5e5..69550b5 100644 --- a/Sources/CarPlayUI/TokamakCore/Animation/Animation.swift +++ b/Sources/CarPlayUI/TokamakCore/Animation/Animation.swift @@ -143,8 +143,8 @@ public struct _AnimationProxy { public struct _AnimationModifier: ViewModifier, Equatable where Value: Equatable { - public var animation: Animation? - public var value: Value + public nonisolated(unsafe) var animation: Animation? + public nonisolated(unsafe) var value: Value @inlinable public init(animation: Animation?, value: Value) { @@ -155,10 +155,9 @@ public struct _AnimationModifier: ViewModifier, Equatable private struct ContentWrapper: View, Equatable { let content: Content let animation: Animation? - let value: Value + nonisolated(unsafe) let value: Value - @State - private var lastValue: Value? + @State private nonisolated(unsafe) var lastValue: Value? var body: some View { content.transaction { @@ -168,7 +167,7 @@ public struct _AnimationModifier: ViewModifier, Equatable } } - static func == (lhs: Self, rhs: Self) -> Bool { + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { lhs.value == rhs.value } } @@ -177,7 +176,7 @@ public struct _AnimationModifier: ViewModifier, Equatable ContentWrapper(content: content, animation: animation, value: value) } - public static func == (lhs: Self, rhs: Self) -> Bool { + public nonisolated static func == (lhs: Self, rhs: Self) -> Bool { lhs.value == rhs.value && lhs.animation == rhs.animation } diff --git a/Sources/CarPlayUI/TokamakCore/Fiber/Fiber+Content.swift b/Sources/CarPlayUI/TokamakCore/Fiber/Fiber+Content.swift index 68886f1..2fca8aa 100644 --- a/Sources/CarPlayUI/TokamakCore/Fiber/Fiber+Content.swift +++ b/Sources/CarPlayUI/TokamakCore/Fiber/Fiber+Content.swift @@ -20,11 +20,11 @@ import Foundation public extension FiberReconciler.Fiber { enum Content { /// The underlying `App` instance and a function to visit it generically. - case app(Any, visit: (AppVisitor) -> ()) + case app(Any, visit: @MainActor (AppVisitor) -> ()) /// The underlying `Scene` instance and a function to visit it generically. - case scene(Any, visit: (SceneVisitor) -> ()) + case scene(Any, visit: @MainActor (SceneVisitor) -> ()) /// The underlying `View` instance and a function to visit it generically. - case view(Any, visit: (ViewVisitor) -> ()) + case view(Any, visit: @MainActor (ViewVisitor) -> ()) } /// Create a `Content` value for a given `App`. diff --git a/Sources/CarPlayUI/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift b/Sources/CarPlayUI/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift index 49f98bb..b8cf756 100644 --- a/Sources/CarPlayUI/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift +++ b/Sources/CarPlayUI/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift @@ -16,14 +16,16 @@ // extension FiberReconciler.Fiber: CustomDebugStringConvertible { - public var debugDescription: String { - let memoryAddress = String(format: "%010p", unsafeBitCast(self, to: Int.self)) - if case let .view(view, _) = content, - let text = view as? Text - { - return "Text(\"\(text.storage.rawText)\") (\(memoryAddress))" + public nonisolated var debugDescription: String { + MainActor.assumeIsolated { + let memoryAddress = String(format: "%010p", unsafeBitCast(self, to: Int.self)) + if case let .view(view, _) = content, + let text = view as? Text + { + return "Text(\"\(text.storage.rawText)\") (\(memoryAddress))" + } + return "\(typeInfo?.name ?? "Unknown") (\(memoryAddress))" } - return "\(typeInfo?.name ?? "Unknown") (\(memoryAddress))" } private func flush(level: Int = 0) -> String { diff --git a/Sources/CarPlayUI/TokamakCore/Fiber/Fiber.swift b/Sources/CarPlayUI/TokamakCore/Fiber/Fiber.swift index a50171e..67ff4dd 100644 --- a/Sources/CarPlayUI/TokamakCore/Fiber/Fiber.swift +++ b/Sources/CarPlayUI/TokamakCore/Fiber/Fiber.swift @@ -39,6 +39,7 @@ public extension FiberReconciler { /// After the entire tree has been traversed, the current and work in progress trees are swapped, /// making the updated tree the current one, /// and leaving the previous current tree available to apply future changes on. + @MainActor final class Fiber { weak var reconciler: FiberReconciler? diff --git a/Sources/CarPlayUI/TokamakCore/Fiber/FiberReconciler.swift b/Sources/CarPlayUI/TokamakCore/Fiber/FiberReconciler.swift index 37c5f1c..1e38c5b 100644 --- a/Sources/CarPlayUI/TokamakCore/Fiber/FiberReconciler.swift +++ b/Sources/CarPlayUI/TokamakCore/Fiber/FiberReconciler.swift @@ -20,6 +20,7 @@ import Combine /// A reconciler modeled after React's /// [Fiber reconciler](https://reactjs.org/docs/faq-internals.html#what-is-react-fiber) +@MainActor public final class FiberReconciler { /// The root node in the `Fiber` tree that represents the `View`s currently rendered on screen. @@ -283,6 +284,7 @@ public extension EnvironmentValues { static let defaultValue: (@escaping () -> ()) -> () = { _ in } } + @MainActor var afterReconcile: (@escaping () -> ()) -> () { get { self[AfterReconcileKey.self] } set { self[AfterReconcileKey.self] = newValue } diff --git a/Sources/CarPlayUI/TokamakCore/Fiber/FiberRenderer.swift b/Sources/CarPlayUI/TokamakCore/Fiber/FiberRenderer.swift index 2057e1d..e60991c 100644 --- a/Sources/CarPlayUI/TokamakCore/Fiber/FiberRenderer.swift +++ b/Sources/CarPlayUI/TokamakCore/Fiber/FiberRenderer.swift @@ -128,6 +128,7 @@ extension EnvironmentValues { } } + @MainActor var measureText: (Text, ProposedViewSize, EnvironmentValues) -> CGSize { get { self[MeasureTextKey.self] } set { self[MeasureTextKey.self] = newValue } @@ -139,6 +140,7 @@ extension EnvironmentValues { } } + @MainActor var measureImage: (Image, ProposedViewSize, EnvironmentValues) -> CGSize { get { self[MeasureImageKey.self] } set { self[MeasureImageKey.self] = newValue } diff --git a/Sources/CarPlayUI/TokamakCore/Fiber/Layout/Layout.swift b/Sources/CarPlayUI/TokamakCore/Fiber/Layout/Layout.swift index d52da8f..3e36d6f 100644 --- a/Sources/CarPlayUI/TokamakCore/Fiber/Layout/Layout.swift +++ b/Sources/CarPlayUI/TokamakCore/Fiber/Layout/Layout.swift @@ -286,11 +286,11 @@ protocol AnyLayoutBox: AnyObject { cache: inout Self.Cache ) -> CGFloat? - var animatableData: _AnyAnimatableData { get set } + nonisolated var animatableData: _AnyAnimatableData { get set } } final class ConcreteLayoutBox: AnyLayoutBox { - var base: L + nonisolated(unsafe) var base: L init(_ base: L) { self.base = base @@ -381,12 +381,12 @@ final class ConcreteLayoutBox: AnyLayoutBox { } } - var animatableData: _AnyAnimatableData { + nonisolated var animatableData: _AnyAnimatableData { get { .init(base.animatableData) } set { - guard let newData = newValue.value as? L.AnimatableData else { return } + guard let newData = newValue.value as? L._AnimatableData else { return } base.animatableData = newData } } @@ -394,7 +394,7 @@ final class ConcreteLayoutBox: AnyLayoutBox { @frozen public struct AnyLayout: Layout { - var storage: AnyLayoutBox + nonisolated(unsafe) var storage: AnyLayoutBox public init(_ layout: L) where L: Layout { storage = ConcreteLayoutBox(layout) @@ -469,7 +469,7 @@ public struct AnyLayout: Layout { ) } - public var animatableData: _AnyAnimatableData { + public nonisolated var animatableData: _AnyAnimatableData { get { _AnyAnimatableData(storage.animatableData) } diff --git a/Sources/CarPlayUI/TokamakCore/Fiber/Layout/LayoutSubviews.swift b/Sources/CarPlayUI/TokamakCore/Fiber/Layout/LayoutSubviews.swift index 2f12987..5786c8e 100644 --- a/Sources/CarPlayUI/TokamakCore/Fiber/Layout/LayoutSubviews.swift +++ b/Sources/CarPlayUI/TokamakCore/Fiber/Layout/LayoutSubviews.swift @@ -27,6 +27,7 @@ public struct LayoutSubviews: Equatable, RandomAccessCollection { self.storage = storage } + @MainActor init(_ node: FiberReconciler.Fiber) { self.init( layoutDirection: node.outputs.environment.environment.layoutDirection, @@ -78,6 +79,7 @@ public struct LayoutSubview: Equatable { private let storage: AnyStorage /// A protocol used to erase `Storage`. + @MainActor private class AnyStorage { let traits: _ViewTraitStore? @@ -193,6 +195,7 @@ public struct LayoutSubview: Equatable { } } + @MainActor init( id: ObjectIdentifier, traits: _ViewTraitStore?, @@ -209,30 +212,37 @@ public struct LayoutSubview: Equatable { ) } + @MainActor public func _trait(key: K.Type) -> K.Value where K: _ViewTraitKey { storage.traits?.value(forKey: key) ?? K.defaultValue } + @MainActor public subscript(key: K.Type) -> K.Value where K: LayoutValueKey { _trait(key: _LayoutTrait.self) } + @MainActor public var priority: Double { _trait(key: LayoutPriorityTraitKey.self) } + @MainActor public func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { storage.sizeThatFits(proposal) } + @MainActor public func dimensions(in proposal: ProposedViewSize) -> ViewDimensions { storage.dimensions(sizeThatFits(proposal)) } + @MainActor public var spacing: ViewSpacing { storage.spacing() } + @MainActor public func place( at position: CGPoint, anchor: UnitPoint = .topLeading, diff --git a/Sources/CarPlayUI/TokamakCore/Fiber/Layout/StackLayout.swift b/Sources/CarPlayUI/TokamakCore/Fiber/Layout/StackLayout.swift index 4442acb..689d6b0 100644 --- a/Sources/CarPlayUI/TokamakCore/Fiber/Layout/StackLayout.swift +++ b/Sources/CarPlayUI/TokamakCore/Fiber/Layout/StackLayout.swift @@ -19,6 +19,7 @@ import Foundation private extension ViewDimensions { /// Access the guide value of an `Alignment` for a particular `Axis`. + @MainActor subscript(alignment alignment: Alignment, in axis: Axis) -> CGFloat { switch axis { case .horizontal: return self[alignment.vertical] diff --git a/Sources/CarPlayUI/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift b/Sources/CarPlayUI/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift index 75ddb9d..1d2649f 100644 --- a/Sources/CarPlayUI/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift +++ b/Sources/CarPlayUI/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift @@ -18,6 +18,7 @@ import Foundation extension FiberReconciler { + @MainActor final class Caches { var elementIndices = [ObjectIdentifier: Int]() var layoutCaches = [ObjectIdentifier: LayoutCache]() diff --git a/Sources/CarPlayUI/TokamakCore/Fiber/ViewArguments.swift b/Sources/CarPlayUI/TokamakCore/Fiber/ViewArguments.swift index 8e81ec5..29eea2a 100644 --- a/Sources/CarPlayUI/TokamakCore/Fiber/ViewArguments.swift +++ b/Sources/CarPlayUI/TokamakCore/Fiber/ViewArguments.swift @@ -85,6 +85,7 @@ public extension View { } public extension ModifiedContent where Content: View, Modifier: ViewModifier { + @MainActor static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { Modifier._makeView(.init( content: inputs.content.modifier, @@ -95,6 +96,7 @@ public extension ModifiedContent where Content: View, Modifier: ViewModifier { )) } + @MainActor func _visitChildren(_ visitor: V) where V: ViewVisitor { modifier._visitChildren(visitor, content: .init(modifier: modifier, view: content)) } diff --git a/Sources/CarPlayUI/TokamakCore/Fiber/ViewGeometry.swift b/Sources/CarPlayUI/TokamakCore/Fiber/ViewGeometry.swift index b9ce867..6fce750 100644 --- a/Sources/CarPlayUI/TokamakCore/Fiber/ViewGeometry.swift +++ b/Sources/CarPlayUI/TokamakCore/Fiber/ViewGeometry.swift @@ -48,10 +48,12 @@ public struct ViewDimensions: Equatable { public var width: CGFloat { size.width } public var height: CGFloat { size.height } + @MainActor public subscript(guide: HorizontalAlignment) -> CGFloat { self[explicit: guide] ?? guide.id.defaultValue(in: self) } + @MainActor public subscript(guide: VerticalAlignment) -> CGFloat { self[explicit: guide] ?? guide.id.defaultValue(in: self) } diff --git a/Sources/CarPlayUI/TokamakCore/Fiber/walk.swift b/Sources/CarPlayUI/TokamakCore/Fiber/walk.swift index d21c984..8673213 100644 --- a/Sources/CarPlayUI/TokamakCore/Fiber/walk.swift +++ b/Sources/CarPlayUI/TokamakCore/Fiber/walk.swift @@ -33,6 +33,7 @@ public enum WalkResult { /// Walk a fiber tree from `root` until the `work` predicate returns `false`. @discardableResult +@MainActor public func walk( _ root: FiberReconciler.Fiber, _ work: @escaping (FiberReconciler.Fiber) throws -> Bool @@ -53,6 +54,7 @@ public func walk( /// /// When the `root` is reached, the loop exits. +@MainActor public func walk( _ root: FiberReconciler.Fiber, _ work: @escaping (FiberReconciler.Fiber) throws -> WalkWorkResult diff --git a/Sources/CarPlayUI/TokamakCore/Modifiers/Effects/ClipEffect.swift b/Sources/CarPlayUI/TokamakCore/Modifiers/Effects/ClipEffect.swift index 2222726..5d27e28 100644 --- a/Sources/CarPlayUI/TokamakCore/Modifiers/Effects/ClipEffect.swift +++ b/Sources/CarPlayUI/TokamakCore/Modifiers/Effects/ClipEffect.swift @@ -30,7 +30,7 @@ public struct _ClipEffect: ViewModifier where ClipShape: Shape { content } - public var animatableData: ClipShape.AnimatableData { + public var animatableData: ClipShape._AnimatableData { get { shape.animatableData } set { shape.animatableData = newValue } } diff --git a/Sources/CarPlayUI/TokamakCore/Modifiers/Effects/OffsetEffect.swift b/Sources/CarPlayUI/TokamakCore/Modifiers/Effects/OffsetEffect.swift index 1b63278..6ca8758 100644 --- a/Sources/CarPlayUI/TokamakCore/Modifiers/Effects/OffsetEffect.swift +++ b/Sources/CarPlayUI/TokamakCore/Modifiers/Effects/OffsetEffect.swift @@ -19,7 +19,7 @@ import Foundation @frozen public struct _OffsetEffect: GeometryEffect, Equatable { - public var offset: CGSize + public nonisolated(unsafe) var offset: CGSize @inlinable public init(offset: CGSize) { @@ -30,7 +30,7 @@ public struct _OffsetEffect: GeometryEffect, Equatable { .init(.init(translationX: offset.width, y: offset.height)) } - public var animatableData: CGSize.AnimatableData { + public nonisolated var animatableData: AnimatablePair { get { offset.animatableData } diff --git a/Sources/CarPlayUI/TokamakCore/Modifiers/Effects/OpacityEffect.swift b/Sources/CarPlayUI/TokamakCore/Modifiers/Effects/OpacityEffect.swift index 382ae05..b083f21 100644 --- a/Sources/CarPlayUI/TokamakCore/Modifiers/Effects/OpacityEffect.swift +++ b/Sources/CarPlayUI/TokamakCore/Modifiers/Effects/OpacityEffect.swift @@ -16,7 +16,7 @@ // public struct _OpacityEffect: Animatable, ViewModifier, Equatable { - public var opacity: Double + public nonisolated var opacity: Double public init(opacity: Double) { self.opacity = opacity @@ -26,7 +26,7 @@ public struct _OpacityEffect: Animatable, ViewModifier, Equatable { content } - public var animatableData: Double { + public nonisolated var animatableData: Double { get { opacity } set { opacity = newValue } } diff --git a/Sources/CarPlayUI/TokamakCore/Modifiers/Effects/RotationEffect.swift b/Sources/CarPlayUI/TokamakCore/Modifiers/Effects/RotationEffect.swift index e9e4c4f..47787f3 100644 --- a/Sources/CarPlayUI/TokamakCore/Modifiers/Effects/RotationEffect.swift +++ b/Sources/CarPlayUI/TokamakCore/Modifiers/Effects/RotationEffect.swift @@ -18,8 +18,8 @@ import Foundation public struct _RotationEffect: GeometryEffect { - public var angle: Angle - public var anchor: UnitPoint + public nonisolated(unsafe) var angle: Angle + public nonisolated(unsafe) var anchor: UnitPoint public init(angle: Angle, anchor: UnitPoint = .center) { self.angle = angle @@ -34,7 +34,7 @@ public struct _RotationEffect: GeometryEffect { content } - public var animatableData: AnimatablePair { + public nonisolated var animatableData: AnimatablePair { get { .init(angle.animatableData, anchor.animatableData) } diff --git a/Sources/CarPlayUI/TokamakCore/Modifiers/FlexFrameLayout.swift b/Sources/CarPlayUI/TokamakCore/Modifiers/FlexFrameLayout.swift index ab5efe8..afaef8a 100644 --- a/Sources/CarPlayUI/TokamakCore/Modifiers/FlexFrameLayout.swift +++ b/Sources/CarPlayUI/TokamakCore/Modifiers/FlexFrameLayout.swift @@ -57,7 +57,7 @@ public struct _FlexFrameLayout: ViewModifier { } extension _FlexFrameLayout: Animatable { - public typealias AnimatableData = EmptyAnimatableData + public typealias _AnimatableData = EmptyAnimatableData } public extension View { diff --git a/Sources/CarPlayUI/TokamakCore/Modifiers/FrameLayout.swift b/Sources/CarPlayUI/TokamakCore/Modifiers/FrameLayout.swift index 0f7d99b..470489d 100644 --- a/Sources/CarPlayUI/TokamakCore/Modifiers/FrameLayout.swift +++ b/Sources/CarPlayUI/TokamakCore/Modifiers/FrameLayout.swift @@ -31,7 +31,7 @@ public struct _FrameLayout: ViewModifier { } extension _FrameLayout: Animatable { - public typealias AnimatableData = EmptyAnimatableData + public typealias _AnimatableData = EmptyAnimatableData } public extension View { diff --git a/Sources/CarPlayUI/TokamakCore/Modifiers/HoverActionModifier.swift b/Sources/CarPlayUI/TokamakCore/Modifiers/HoverActionModifier.swift index 190d3f7..1336a24 100644 --- a/Sources/CarPlayUI/TokamakCore/Modifiers/HoverActionModifier.swift +++ b/Sources/CarPlayUI/TokamakCore/Modifiers/HoverActionModifier.swift @@ -21,6 +21,7 @@ public struct _HoverActionModifier: ViewModifier { extension ModifiedContent where Content: View, Modifier == _HoverActionModifier { + @MainActor var hover: ((Bool) -> ())? { modifier.hover } } diff --git a/Sources/CarPlayUI/TokamakCore/Modifiers/ModifiedContent.swift b/Sources/CarPlayUI/TokamakCore/Modifiers/ModifiedContent.swift index 45156be..7efe6f3 100644 --- a/Sources/CarPlayUI/TokamakCore/Modifiers/ModifiedContent.swift +++ b/Sources/CarPlayUI/TokamakCore/Modifiers/ModifiedContent.swift @@ -27,6 +27,7 @@ public struct ModifiedContent: ModifiedContentProtocol { public private(set) var content: Content public private(set) var modifier: Modifier + @MainActor public init(content: Content, modifier: Modifier) { self.content = content self.modifier = modifier diff --git a/Sources/CarPlayUI/TokamakCore/Modifiers/PaddingLayout.swift b/Sources/CarPlayUI/TokamakCore/Modifiers/PaddingLayout.swift index bc747c5..3045de6 100644 --- a/Sources/CarPlayUI/TokamakCore/Modifiers/PaddingLayout.swift +++ b/Sources/CarPlayUI/TokamakCore/Modifiers/PaddingLayout.swift @@ -29,7 +29,7 @@ public struct _PaddingLayout: ViewModifier { } extension _PaddingLayout: Animatable { - public typealias AnimatableData = EmptyAnimatableData + public typealias _AnimatableData = EmptyAnimatableData } public extension View { @@ -51,6 +51,7 @@ public extension View { } public extension ModifiedContent where Modifier == _PaddingLayout, Content: View { + @MainActor func padding(_ length: CGFloat) -> ModifiedContent { var layout = modifier layout.insets?.top += length diff --git a/Sources/CarPlayUI/TokamakCore/Modifiers/ShadowLayout.swift b/Sources/CarPlayUI/TokamakCore/Modifiers/ShadowLayout.swift index 30e1fd0..08cd7b8 100644 --- a/Sources/CarPlayUI/TokamakCore/Modifiers/ShadowLayout.swift +++ b/Sources/CarPlayUI/TokamakCore/Modifiers/ShadowLayout.swift @@ -39,15 +39,15 @@ public struct _ShadowEffect: EnvironmentalModifier, Equatable { } public struct _Resolved: ViewModifier, Animatable { - public var color: AnyColorBox.ResolvedValue - public var radius: CGFloat - public var offset: CGSize + public nonisolated(unsafe) var color: AnyColorBox.ResolvedValue + public nonisolated var radius: CGFloat + public nonisolated(unsafe) var offset: CGSize public func body(content: Content) -> some View { content } - public typealias AnimatableData = AnimatablePair< + public typealias _AnimatableData = AnimatablePair< AnimatablePair< Float, AnimatablePair< @@ -55,9 +55,9 @@ public struct _ShadowEffect: EnvironmentalModifier, Equatable { AnimatablePair > >, - AnimatablePair + AnimatablePair> > - public var animatableData: _Resolved.AnimatableData { + public nonisolated var animatableData: _Resolved._AnimatableData { get { .init( .init( diff --git a/Sources/CarPlayUI/TokamakCore/Modifiers/TaskModifier.swift b/Sources/CarPlayUI/TokamakCore/Modifiers/TaskModifier.swift index 5d9848a..548273a 100644 --- a/Sources/CarPlayUI/TokamakCore/Modifiers/TaskModifier.swift +++ b/Sources/CarPlayUI/TokamakCore/Modifiers/TaskModifier.swift @@ -20,14 +20,13 @@ internal struct _TaskModifierView: View where Content: View { let priority: TaskPriority - let action: @Sendable () async -> () + let action: @isolated(any) @Sendable () async -> () let content: Content - @State - nonisolated(unsafe) private var task: Task<(), Never>? + @State private nonisolated(unsafe) var task: Task<(), Never>? - init(priority: TaskPriority, action: @escaping @Sendable () async -> (), content: Content) { + init(priority: TaskPriority, action: @escaping @isolated(any) @Sendable () async -> (), content: Content) { self.priority = priority self.action = action self.content = content @@ -48,7 +47,7 @@ internal struct _TaskModifierView: View where Content: View { public extension View { func task( priority: TaskPriority = .userInitiated, - _ action: @escaping @Sendable () async -> () + _ action: @escaping @isolated(any) @Sendable () async -> () ) -> some View { _TaskModifierView(priority: priority, action: action, content: self) } diff --git a/Sources/CarPlayUI/TokamakCore/Preferences/_PreferenceReadingView.swift b/Sources/CarPlayUI/TokamakCore/Preferences/_PreferenceReadingView.swift index 8a795ed..84558b3 100644 --- a/Sources/CarPlayUI/TokamakCore/Preferences/_PreferenceReadingView.swift +++ b/Sources/CarPlayUI/TokamakCore/Preferences/_PreferenceReadingView.swift @@ -20,8 +20,7 @@ public struct _DelayedPreferenceView: View, _PreferenceReadingViewProtocol where Key: PreferenceKey, Content: View { - @State - nonisolated(unsafe) private var resolvedValue: _PreferenceValue = _PreferenceValue(storage: .init(Key.self)) + @State private nonisolated(unsafe) var resolvedValue: _PreferenceValue = _PreferenceValue(storage: .init(Key.self)) public let transform: (_PreferenceValue) -> Content private var valueReference: _PreferenceValue? diff --git a/Sources/CarPlayUI/TokamakCore/Shapes/AnyShape.swift b/Sources/CarPlayUI/TokamakCore/Shapes/AnyShape.swift index 78fa0b8..16755e8 100644 --- a/Sources/CarPlayUI/TokamakCore/Shapes/AnyShape.swift +++ b/Sources/CarPlayUI/TokamakCore/Shapes/AnyShape.swift @@ -30,7 +30,7 @@ private struct ConcreteAnyShapeBox: AnyShapeBox { _AnyAnimatableData(base.animatableData) } set { - guard let newData = newValue.value as? Base.AnimatableData else { + guard let newData = newValue.value as? Base._AnimatableData else { // TODO: Should this crash? return } diff --git a/Sources/CarPlayUI/TokamakCore/Shapes/ModifiedShapes.swift b/Sources/CarPlayUI/TokamakCore/Shapes/ModifiedShapes.swift index b870bc5..689aac9 100644 --- a/Sources/CarPlayUI/TokamakCore/Shapes/ModifiedShapes.swift +++ b/Sources/CarPlayUI/TokamakCore/Shapes/ModifiedShapes.swift @@ -37,8 +37,8 @@ public struct _StrokedShape: Shape, DynamicProperty where S: Shape { public static var role: ShapeRole { .stroke } - public typealias AnimatableData = AnimatablePair - public nonisolated var animatableData: AnimatableData { + public typealias _AnimatableData = AnimatablePair + public nonisolated var animatableData: _AnimatableData { get { .init(shape.animatableData, style.animatableData) } @@ -65,11 +65,11 @@ public struct _TrimmedShape: Shape where S: Shape { .trimmedPath(from: startFraction, to: endFraction) } - public typealias AnimatableData = AnimatablePair< - S.AnimatableData, + public typealias _AnimatableData = AnimatablePair< + S._AnimatableData, AnimatablePair > - public nonisolated var animatableData: AnimatableData { + public nonisolated var animatableData: _AnimatableData { get { .init(shape.animatableData, .init(startFraction, endFraction)) } @@ -81,8 +81,8 @@ public struct _TrimmedShape: Shape where S: Shape { } public struct OffsetShape: Shape where Content: Shape { - public var shape: Content - public var offset: CGSize + public nonisolated(unsafe) var shape: Content + public nonisolated(unsafe) var offset: CGSize public init(shape: Content, offset: CGSize) { self.shape = shape @@ -95,8 +95,8 @@ public struct OffsetShape: Shape where Content: Shape { .offsetBy(dx: offset.width, dy: offset.height) } - public typealias AnimatableData = AnimatablePair - public var animatableData: AnimatableData { + public typealias _AnimatableData = AnimatablePair> + public nonisolated var animatableData: _AnimatableData { get { .init(shape.animatableData, offset.animatableData) } @@ -115,9 +115,9 @@ extension OffsetShape: InsettableShape where Content: InsettableShape { } public struct ScaledShape: Shape where Content: Shape { - public var shape: Content - public var scale: CGSize - public var anchor: UnitPoint + public nonisolated(unsafe) var shape: Content + public nonisolated(unsafe) var scale: CGSize + public nonisolated(unsafe) var anchor: UnitPoint public init(shape: Content, scale: CGSize, anchor: UnitPoint = .center) { self.shape = shape @@ -131,11 +131,11 @@ public struct ScaledShape: Shape where Content: Shape { .applying(.init(scaleX: scale.width, y: scale.height)) } - public typealias AnimatableData = AnimatablePair< - Content.AnimatableData, - AnimatablePair + public typealias _AnimatableData = AnimatablePair< + Content._AnimatableData, + AnimatablePair, UnitPoint._AnimatableData> > - public var animatableData: AnimatableData { + public nonisolated var animatableData: _AnimatableData { get { .init(shape.animatableData, .init(scale.animatableData, anchor.animatableData)) } @@ -163,11 +163,11 @@ public struct RotatedShape: Shape where Content: Shape { .applying(.init(rotationAngle: CGFloat(angle.radians))) } - public typealias AnimatableData = AnimatablePair< - Content.AnimatableData, - AnimatablePair + public typealias _AnimatableData = AnimatablePair< + Content._AnimatableData, + AnimatablePair > - public nonisolated var animatableData: AnimatableData { + public nonisolated var animatableData: _AnimatableData { get { .init(shape.animatableData, .init(angle.animatableData, anchor.animatableData)) } @@ -199,15 +199,15 @@ public struct TransformedShape: Shape where Content: Shape { .applying(transform) } - public nonisolated var animatableData: Content.AnimatableData { + public nonisolated var animatableData: Content._AnimatableData { get { shape.animatableData } set { shape.animatableData = newValue } } } public struct _SizedShape: Shape where S: Shape { - public var shape: S - public var size: CGSize + public nonisolated(unsafe) var shape: S + public nonisolated(unsafe) var size: CGSize public init(shape: S, size: CGSize) { self.shape = shape @@ -220,8 +220,8 @@ public struct _SizedShape: Shape where S: Shape { .path(in: rect) } - public typealias AnimatableData = AnimatablePair - public var animatableData: AnimatableData { + public typealias _AnimatableData = AnimatablePair> + public nonisolated var animatableData: _AnimatableData { get { .init(shape.animatableData, size.animatableData) } diff --git a/Sources/CarPlayUI/TokamakCore/StackReconciler.swift b/Sources/CarPlayUI/TokamakCore/StackReconciler.swift index 71bfc48..db315a7 100644 --- a/Sources/CarPlayUI/TokamakCore/StackReconciler.swift +++ b/Sources/CarPlayUI/TokamakCore/StackReconciler.swift @@ -16,6 +16,7 @@ // import Combine +import Observation /** A class that reconciles a "raw" tree of element values (such as `App`, `Scene` and `View`, all coming from `body` or `renderedBody` properties) with a tree of mounted element instances @@ -38,6 +39,7 @@ public final class StackReconciler { */ private var queuedRerenders = Set() + @MainActor struct Rerender: Hashable { let element: MountedCompositeElement let transaction: Transaction @@ -155,6 +157,10 @@ public final class StackReconciler { queuedRerenders.removeAll() for mountedView in queued { + // Skip elements that have already been unmounted. This can happen when an Observable + // `onChange` Task is in-flight while the element is removed from the tree (e.g., a + // navigation pop), leaving a stale entry in `queuedRerenders`. + guard mountedView.element.transitionPhase != .willUnmount else { continue } mountedView.element.update(in: self, with: mountedView.transaction) } @@ -262,7 +268,20 @@ public final class StackReconciler { let view = body(of: compositeView, keyPath: \.view.view) guard let renderedBody = renderer.primitiveBody(for: view) else { - return compositeView.view.bodyClosure(view) + // Track any `@Observable` properties read during `body`, so mutating them later + // schedules a re-render the same way a `@State` setter or `ObservableObject` + // `objectWillChange` emission does. `render(compositeView:)` re-runs on every + // mount and re-render, which naturally re-establishes tracking each time. + let reconcilerBox = WeakBox(value: self) + let elementBox = WeakBox(value: compositeView) + return withObservationTracking { + compositeView.view.bodyClosure(view) + } onChange: { + Task { @MainActor in + guard let reconciler = reconcilerBox.value, let element = elementBox.value else { return } + reconciler.queueUpdate(for: element, transaction: .init(animation: nil)) + } + } } return renderedBody @@ -328,3 +347,10 @@ public final class StackReconciler { queuedPostrenderCallbacks.removeAll() } } + +/// Smuggles a weak reference to a MainActor-isolated, non-`Sendable` object across the +/// non-isolated `@Sendable` `onChange` closure of `withObservationTracking`. Safe because +/// `.value` is only read after hopping back onto the main actor via `Task { @MainActor in }`. +private struct WeakBox: @unchecked Sendable { + weak var value: T? +} diff --git a/Sources/CarPlayUI/TokamakCore/Styles/ToggleStyle.swift b/Sources/CarPlayUI/TokamakCore/Styles/ToggleStyle.swift index 72b2a2c..16ff8f1 100644 --- a/Sources/CarPlayUI/TokamakCore/Styles/ToggleStyle.swift +++ b/Sources/CarPlayUI/TokamakCore/Styles/ToggleStyle.swift @@ -23,7 +23,7 @@ // It seems like during the rendering process it’s dynamically replaced with the actual label. // That’s complicated so instead we’re providing the label view directly. -public struct ToggleStyleConfiguration { +public nonisolated struct ToggleStyleConfiguration { public let label: AnyView @Binding public var isOn: Swift.Bool diff --git a/Sources/CarPlayUI/TokamakCore/Tokens/Color/Color.swift b/Sources/CarPlayUI/TokamakCore/Tokens/Color/Color.swift index f6a9ebc..d9d14a9 100644 --- a/Sources/CarPlayUI/TokamakCore/Tokens/Color/Color.swift +++ b/Sources/CarPlayUI/TokamakCore/Tokens/Color/Color.swift @@ -59,9 +59,9 @@ public struct Color: Hashable, Equatable, Sendable { /// Create a `Color` dependent on the current `ColorScheme`. - public static func _withScheme(_ resolver: @escaping (ColorScheme) -> Self) -> Self { + public static func _withScheme(_ resolver: @escaping @MainActor (ColorScheme) -> Self) -> Self { .init(_EnvironmentDependentColorBox { environment in - MainActor.assumeIsolated { resolver(environment.colorScheme) } + resolver(environment.colorScheme) }) } } @@ -76,7 +76,7 @@ public struct _ColorProxy { let subject: Color public init(_ subject: Color) { self.subject = subject } @MainActor - public func resolve(in environment: EnvironmentValues) -> AnyColorBox.ResolvedValue { + public func resolve(in environment: sending EnvironmentValues) -> AnyColorBox.ResolvedValue { if let deferred = subject.provider as? AnyColorBoxDeferredToRenderer { return deferred.deferredResolve(in: environment) } else { @@ -123,7 +123,7 @@ public extension Color { nonisolated(unsafe) static let secondary: Self = .init(systemColor: .secondary) nonisolated(unsafe) static let accentColor: Self = .init(_EnvironmentDependentColorBox { environment in - MainActor.assumeIsolated { environment.accentColor ?? Self.blue } + environment.accentColor ?? Self.blue }) init(_ color: UIColor) { diff --git a/Sources/CarPlayUI/TokamakCore/Tokens/Color/ColorBoxes.swift b/Sources/CarPlayUI/TokamakCore/Tokens/Color/ColorBoxes.swift index 3d0e5a2..c830551 100644 --- a/Sources/CarPlayUI/TokamakCore/Tokens/Color/ColorBoxes.swift +++ b/Sources/CarPlayUI/TokamakCore/Tokens/Color/ColorBoxes.swift @@ -37,7 +37,7 @@ /// @MainActor public protocol AnyColorBoxDeferredToRenderer: AnyColorBox { - func deferredResolve(in environment: EnvironmentValues) -> AnyColorBox.ResolvedValue + func deferredResolve(in environment: sending EnvironmentValues) -> AnyColorBox.ResolvedValue } public class AnyColorBox: AnyTokenBox, Hashable, @unchecked Sendable { @@ -103,7 +103,7 @@ public final class _ConcreteColorBox: AnyColorBox { } public final class _EnvironmentDependentColorBox: AnyColorBox { - public let resolver: (EnvironmentValues) -> Color + public let resolver: @MainActor (EnvironmentValues) -> Color override public func equals(_ other: AnyColorBox) -> Bool { guard let other = other as? _EnvironmentDependentColorBox @@ -117,11 +117,11 @@ public final class _EnvironmentDependentColorBox: AnyColorBox { hasher.combine(MainActor.assumeIsolated { resolver(EnvironmentValues()) }) } - init(_ resolver: @escaping (EnvironmentValues) -> Color) { + init(_ resolver: @escaping @MainActor (EnvironmentValues) -> Color) { self.resolver = resolver } - override public func resolve(in environment: EnvironmentValues) -> ResolvedValue { + override public func resolve(in environment: sending EnvironmentValues) -> ResolvedValue { MainActor.assumeIsolated { resolver(environment).provider.resolve(in: environment) } @@ -197,7 +197,7 @@ public final class _SystemColorBox: AnyColorBox, CustomStringConvertible { self.value = value } - override public func resolve(in environment: EnvironmentValues) -> ResolvedValue { + override public func resolve(in environment: sending EnvironmentValues) -> ResolvedValue { switch MainActor.assumeIsolated({ environment.colorScheme }) { case .light: switch value { diff --git a/Sources/CarPlayUI/TokamakCore/Tokens/Edge.swift b/Sources/CarPlayUI/TokamakCore/Tokens/Edge.swift index 67238c1..b646e0e 100644 --- a/Sources/CarPlayUI/TokamakCore/Tokens/Edge.swift +++ b/Sources/CarPlayUI/TokamakCore/Tokens/Edge.swift @@ -67,7 +67,7 @@ public struct EdgeInsets: Equatable { } extension EdgeInsets: Animatable, _VectorMath { - public typealias AnimatableData = AnimatablePair< + public typealias _AnimatableData = AnimatablePair< CGFloat, AnimatablePair< CGFloat, @@ -75,7 +75,7 @@ extension EdgeInsets: Animatable, _VectorMath { > > - public var animatableData: AnimatableData { + public var animatableData: _AnimatableData { @inlinable get { .init(top, .init(leading, .init(bottom, trailing))) } diff --git a/Sources/CarPlayUI/TokamakCore/Views/Containers/DisclosureGroup.swift b/Sources/CarPlayUI/TokamakCore/Views/Containers/DisclosureGroup.swift index 70f1996..d8789bf 100644 --- a/Sources/CarPlayUI/TokamakCore/Views/Containers/DisclosureGroup.swift +++ b/Sources/CarPlayUI/TokamakCore/Views/Containers/DisclosureGroup.swift @@ -16,8 +16,7 @@ // public struct DisclosureGroup: _PrimitiveView where Label: View, Content: View { - @State - nonisolated(unsafe) var isExpanded: Bool = false + @State nonisolated(unsafe) var isExpanded: Bool = false let isExpandedBinding: Binding? @Environment(\._outlineGroupStyle) diff --git a/Sources/CarPlayUI/TokamakCore/Views/Containers/List/List.swift b/Sources/CarPlayUI/TokamakCore/Views/Containers/List/List.swift index 67585d8..5d6745c 100644 --- a/Sources/CarPlayUI/TokamakCore/Views/Containers/List/List.swift +++ b/Sources/CarPlayUI/TokamakCore/Views/Containers/List/List.swift @@ -29,11 +29,13 @@ public struct List @Environment(\.listStyle) var style + @MainActor public init(selection: Binding>?, @ViewBuilder content: () -> Content) { self.selection = .many(selection) self.content = content() } + @MainActor public init(selection: Binding?, @ViewBuilder content: () -> Content) { self.selection = .one(selection) self.content = content() diff --git a/Sources/CarPlayUI/TokamakCore/Views/Containers/Section.swift b/Sources/CarPlayUI/TokamakCore/Views/Containers/Section.swift index 11a470f..f526cec 100644 --- a/Sources/CarPlayUI/TokamakCore/Views/Containers/Section.swift +++ b/Sources/CarPlayUI/TokamakCore/Views/Containers/Section.swift @@ -44,6 +44,7 @@ extension Section: View, SectionView where Parent: View, Content: View, Footer: footer } + @MainActor func sectionContent(_ style: ListStyle) -> AnyView { if let contentContainer = content as? ParentView { let rows = _ListRow.buildItems(contentContainer.children) { view, isLast in @@ -61,6 +62,7 @@ extension Section: View, SectionView where Parent: View, Content: View, Footer: } } + @MainActor func footerView(_ style: ListStyle) -> AnyView { if footer is EmptyView { return AnyView(EmptyView()) @@ -71,6 +73,7 @@ extension Section: View, SectionView where Parent: View, Content: View, Footer: } } + @MainActor func headerView(_ style: ListStyle) -> AnyView { if header is EmptyView { return AnyView(EmptyView()) diff --git a/Sources/CarPlayUI/TokamakCore/Views/Containers/VariadicView.swift b/Sources/CarPlayUI/TokamakCore/Views/Containers/VariadicView.swift index 542511a..6cd339d 100644 --- a/Sources/CarPlayUI/TokamakCore/Views/Containers/VariadicView.swift +++ b/Sources/CarPlayUI/TokamakCore/Views/Containers/VariadicView.swift @@ -66,7 +66,7 @@ public struct _VariadicView_Children { extension _VariadicView_Children: RandomAccessCollection { public struct Element: View, Identifiable { let view: AnyView - public var id: AnyHashable + public nonisolated(unsafe) var id: AnyHashable let viewTraits: _ViewTraitStore let onTraitsUpdated: (_ViewTraitStore) -> () diff --git a/Sources/CarPlayUI/TokamakCore/Views/Controls/ButtonBase.swift b/Sources/CarPlayUI/TokamakCore/Views/Controls/ButtonBase.swift index 5314873..7894e8a 100644 --- a/Sources/CarPlayUI/TokamakCore/Views/Controls/ButtonBase.swift +++ b/Sources/CarPlayUI/TokamakCore/Views/Controls/ButtonBase.swift @@ -103,8 +103,7 @@ public struct _Button