From a9db17c72eac80130f18b9bd94faff1e3b535b4f Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 20 Sep 2026 00:53:52 +0800 Subject: [PATCH 01/22] feat(focus): add default focus evaluation priority --- .../DefaultFocusEvaluationPriority.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 Sources/OpenSwiftUI/Event/Focus/DefaultFocusEvaluationPriority.swift diff --git a/Sources/OpenSwiftUI/Event/Focus/DefaultFocusEvaluationPriority.swift b/Sources/OpenSwiftUI/Event/Focus/DefaultFocusEvaluationPriority.swift new file mode 100644 index 000000000..ea6672869 --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Focus/DefaultFocusEvaluationPriority.swift @@ -0,0 +1,22 @@ +// +// DefaultFocusEvaluationPriority.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +/// Prioritizations for default focus preferences when evaluating where +/// to move focus in different circumstances. +@available(OpenSwiftUI_v4_0, *) +public struct DefaultFocusEvaluationPriority: Sendable { + let rawValue: Int + + /// Use the default focus preference when focus moves into the affected + /// branch automatically, but ignore it when the movement is driven by a + /// user-initiated navigation command. + public static let automatic: DefaultFocusEvaluationPriority = .init(rawValue: 0) + + /// Always use the default focus preference when focus moves into the + /// affected branch. + public static let userInitiated: DefaultFocusEvaluationPriority = .init(rawValue: 1) +} From 668aa7097476fa99ec854b90fceee872199b9c1a Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 20 Sep 2026 03:12:19 +0800 Subject: [PATCH 02/22] feat(focus): implement default focus evaluation --- .../Focus/EvaluateDefaultFocusAction.swift | 59 ++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/Sources/OpenSwiftUI/Event/Focus/EvaluateDefaultFocusAction.swift b/Sources/OpenSwiftUI/Event/Focus/EvaluateDefaultFocusAction.swift index 39c10eb34..19b78e69e 100644 --- a/Sources/OpenSwiftUI/Event/Focus/EvaluateDefaultFocusAction.swift +++ b/Sources/OpenSwiftUI/Event/Focus/EvaluateDefaultFocusAction.swift @@ -3,6 +3,61 @@ // OpenSwiftUI // // Audited for 6.5.4 -// Status: WIP +// Status: Complete +// ID: 9993DE8943AC71AF21213EB6FCE0DB61 (SwiftUI?) -struct EvaluateDefaultFocusAction {} +#if os(macOS) +import OpenSwiftUICore + +// MARK: - EvaluateDefaultFocusAction + +struct EvaluateDefaultFocusAction: Hashable { + var namespace: Namespace.ID + var priority: DefaultFocusEvaluationPriority + var onEvaluate: (() -> (any BaseFocusResponder)?)? + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.namespace == rhs.namespace + } + + func hash(into hasher: inout Hasher) { + hasher.combine(namespace) + } +} + +// MARK: - DefaultFocusStateModifier + +struct DefaultFocusStateModifier: ViewModifier where A: Hashable { + var binding: FocusState.Binding + var value: A + var priority: DefaultFocusEvaluationPriority + @Namespace var namespace + + init(binding: FocusState.Binding, value: A, priority: DefaultFocusEvaluationPriority) { + self.binding = binding + self.value = value + self.priority = priority + } + + func body(content: Content) -> some View { + content.environment(\.evaluateDefaultFocus, EvaluateDefaultFocusAction( + namespace: namespace, + priority: priority, + onEvaluate: { binding.location.findEntry(with: value)?.responder } + )) + } +} + +// MARK: - EnvironmentValues + Default Focus + +extension EnvironmentValues { + var evaluateDefaultFocus: EvaluateDefaultFocusAction? { + get { self[EvaluateDefaultFocusKey.self] } + set { self[EvaluateDefaultFocusKey.self] = newValue } + } + + private struct EvaluateDefaultFocusKey: EnvironmentKey { + static var defaultValue: EvaluateDefaultFocusAction? { nil } + } +} +#endif From 5206d0c6318b5c578fcc95fcd9ea8ee73467c506 Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 24 Sep 2026 20:18:06 +0800 Subject: [PATCH 03/22] feat(focus): add focusable view APIs --- .../OpenSwiftUI/Event/Focus/Focusable.swift | 397 ++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 Sources/OpenSwiftUI/Event/Focus/Focusable.swift diff --git a/Sources/OpenSwiftUI/Event/Focus/Focusable.swift b/Sources/OpenSwiftUI/Event/Focus/Focusable.swift new file mode 100644 index 000000000..d43b93176 --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Focus/Focusable.swift @@ -0,0 +1,397 @@ +// +// Focusable.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: WIP +// ID: B6A2D4E72E5722B5103497ADB7778B5F (SwiftUI) + +import Foundation +import OpenAttributeGraphShims +@_spi(Private) +@_spi(ForOpenSwiftUIOnly) +public import OpenSwiftUICore + +#if os(iOS) || os(visionOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +// MARK: - View + Focus + +extension View { + /// Specifies if the view is focusable. + /// + /// - Parameter isFocusable: A Boolean value that indicates whether this + /// view is focusable. + /// + /// - Returns: A view that sets whether a view is focusable. + @available(OpenSwiftUI_v3_0, *) + nonisolated public func focusable(_ isFocusable: Bool = true) -> some View { + modifier(_FocusableModifier( + isFocusable: isFocusable, + configuration: FocusInteractions.automatic, + onFocusChange: { _ in } + )) + } + + /// Specifies if the view is focusable, and if so, what focus-driven + /// interactions it supports. + /// + /// By default, OpenSwiftUI enables all possible focus interactions. However, on + /// macOS and iOS it is conventional for button-like views to only accept + /// focus when the user has enabled keyboard navigation system-wide in the + /// Settings app. Clients can reproduce this behavior with custom views by + /// only supporting `.activate` interactions. + /// + /// MyTapGestureView(...) + /// .focusable(interactions: .activate) + /// + /// - Note: The focus interactions allowed for custom views changed in + /// macOS 14—previously, custom views could only become focused with + /// keyboard navigation enabled system-wide. Clients built using older + /// SDKs will continue to see the older focus behavior, while custom views + /// in clients built using macOS 14 or later will always be focusable + /// unless the client requests otherwise by specifying a restricted set of + /// focus interactions. + /// + /// - Parameters: + /// - isFocusable: `true` if the view should participate in focus; + /// `false` otherwise. The default value is `true`. + /// - interactions: The types of focus interactions supported by the view. + /// The default value is `.automatic`. + /// - Returns: A view that sets whether its child is focusable. + @available(OpenSwiftUI_v5_0, *) + nonisolated public func focusable(_ isFocusable: Bool = true, interactions: FocusInteractions) -> some View { + modifier(_FocusableModifier( + isFocusable: isFocusable, + configuration: interactions, + onFocusChange: { _ in } + )) + } + + @_spi(_) + @available(OpenSwiftUI_v5_0, *) + @available(*, deprecated, message: "Use focusEffectDisabled(_:) instead") + nonisolated public func focusEffect(_ effect: FocusEffect) -> some View { + environment(\.focusEffect, effect) + } + + /// Adds a condition that controls whether this view can display focus + /// effects, such as a default focus ring or hover effect. + /// + /// The higher views in a view hierarchy can override the value you set on + /// this view. In the following example, the button does not display a focus + /// effect because the outer `focusEffectDisabled(_:)` modifier overrides + /// the inner one: + /// + /// HStack { + /// Button("Press") {} + /// .focusEffectDisabled(false) + /// } + /// .focusEffectDisabled(true) + /// + /// - Parameter disabled: A Boolean value that determines whether this view + /// can display focus effects. + /// - Returns: A view that controls whether focus effects can be displayed + /// in this view. + @available(OpenSwiftUI_v5_0, *) + nonisolated public func focusEffectDisabled(_ disabled: Bool = true) -> some View { + transformEnvironment(\.isFocusEffectEnabled) { + $0 = $0 && !disabled + } + } +} + +// MARK: - FocusInteractions + +/// Values describe different focus interactions that a view can support. +@available(OpenSwiftUI_v5_0, *) +public struct FocusInteractions: OptionSet, Sendable { + /// The view has a primary action that can be activated via focus gestures. + /// + /// On macOS and iOS, focus-driven activation interactions are only possible + /// when all-controls keyboard navigation is enabled. On tvOS and watchOS, + /// focus-driven activation interactions are always possible. + public static let activate: FocusInteractions = .init(rawValue: 1 << 0) + + /// The view captures input from non-spatial sources like a keyboard or + /// Digital Crown. + /// + /// Views that support focus-driven editing interactions become focused when + /// the user taps or clicks on them, or when the user issues a focus + /// movement command. + public static let edit: FocusInteractions = .init(rawValue: 1 << 1) + + @_spi(Private) + public static let navigate: FocusInteractions = .init(rawValue: 1 << 2) + + /// The view supports whatever focus-driven interactions are commonly + /// expected for interactive content on the current platform. + public static var automatic: FocusInteractions { [.activate, .edit] } + + public var rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } +} + +// MARK: - FocusEffect + +@_spi(_) +@available(OpenSwiftUI_v5_0, *) +@available(*, deprecated, message: "Use focusEffectDisabled(_:) instead") +public struct FocusEffect: Equatable, Sendable { + enum Kind { + case automatic + case disabled + } + + var kind: Kind + + public static let automatic: FocusEffect = .init(kind: .automatic) + + public static let disabled: FocusEffect = .init(kind: .disabled) +} + +// MARK: - EnvironmentValues + Focus Effect + +@available(OpenSwiftUI_v5_0, *) +extension EnvironmentValues { + @_spi(_) + @available(*, deprecated, message: "Use isFocusEffectEnabled instead") + public fileprivate(set) var focusEffect: FocusEffect { + get { isFocusEffectEnabled ? .automatic : .disabled } + set { isFocusEffectEnabled = newValue.kind == .automatic } + } + + private struct IsFocusEffectEnabledKey: EnvironmentKey { + static var defaultValue: Bool { true } + } + + /// A Boolean value that indicates whether the view associated with this + /// environment allows focus effects to be displayed. + /// + /// The default value is `true`. + public var isFocusEffectEnabled: Bool { + get { self[IsFocusEffectEnabledKey.self] } + set { self[IsFocusEffectEnabledKey.self] = newValue } + } +} + +// MARK: - _FocusableModifier + +@available(OpenSwiftUI_v1_0, *) // iOS 13.4 +public struct _FocusableModifier: ViewModifier, MultiViewModifier, PrimitiveViewModifier { + var isFocusable: Bool + + var configuration: any _FocusableModifier_Configuration + + var onFocusChange: (Bool) -> Void + + nonisolated public static func _makeView( + modifier: _GraphValue, + inputs: _ViewInputs, + body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs + ) -> _ViewOutputs { + #if os(macOS) + _openSwiftUIUnimplementedFailure() + #else + _openSwiftUIUnimplementedFailure() + #endif + } +} + +@available(*, unavailable) +extension _FocusableModifier: Sendable {} + +// MARK: - _FocusableModifier_Configuration + +protocol _FocusableModifier_Configuration { + func resolve(in environment: EnvironmentValues) -> FocusableOptions +} + +// MARK: - FocusableOptions + +@_spi(Private) +@available(OpenSwiftUI_v3_0, *) +public struct FocusableOptions: OptionSet { + public static let fromMouse: FocusableOptions = .init(rawValue: 1 << 0) + + public static let fromKeyboard: FocusableOptions = .init(rawValue: 1 << 1) + + public static let platformItemDrawsFocusRingMask: FocusableOptions = .init(rawValue: 1 << 2) + + public static let platformContainerHandlesFocus: FocusableOptions = .init(rawValue: 1 << 3) + + public static let preventNavigationToSubviews: FocusableOptions = .init(rawValue: 1 << 4) + + public static let fromClientUpdate: FocusableOptions = .init(rawValue: 1 << 5) + + public static let all: FocusableOptions = [.fromMouse, .fromKeyboard, .platformItemDrawsFocusRingMask] + + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } +} + +@_spi(Private) +@available(*, unavailable) +extension FocusableOptions: Sendable {} + +extension FocusInteractions: _FocusableModifier_Configuration { + func resolve(in environment: EnvironmentValues) -> FocusableOptions { + var options: FocusableOptions = [] + if isEmpty { + options = .fromClientUpdate + } else { + #if OPENSWIFTUI_RELEASE_2026 + if contains(.activate) { + options = [.fromClientUpdate] + } + if contains(.edit) { + options = [.fromClientUpdate, .fromKeyboard, .fromMouse] + } + if contains(.navigate) { + options.formUnion([.fromClientUpdate, .fromKeyboard]) + } + #else + if contains(.activate), environment.allControlsNavigable { + options = [.fromClientUpdate, .fromKeyboard] + } + if contains(.navigate) { + options.formUnion([.fromClientUpdate, .fromKeyboard]) + } + if contains(.edit) { + options.formUnion([.fromClientUpdate, .fromKeyboard, .fromMouse]) + } + #endif + } + if !environment.isFocusEffectEnabled { + options.insert(.platformItemDrawsFocusRingMask) + } + return options + } +} + +extension FocusableOptions: _FocusableModifier_Configuration { + func resolve(in environment: EnvironmentValues) -> FocusableOptions { + var options = self + if environment.allControlsNavigable { + options.formUnion(.fromKeyboard) + } + if !environment.isFocusEffectEnabled { + options.formUnion(.platformItemDrawsFocusRingMask) + } + if !options.isDisjoint(with: [.fromMouse, .fromKeyboard]) { + options.formUnion(.fromClientUpdate) + } + return options + } +} + +// MARK: - EnvironmentValues + Focus + +extension EnvironmentValues { + private struct IsPlatformFocusSystemEnabled: EnvironmentKey { + static var defaultValue: Bool { + #if os(macOS) && !targetEnvironment(macCatalyst) + true + #else + false + #endif + } + } + + var isPlatformFocusSystemEnabled: Bool { + get { self[IsPlatformFocusSystemEnabled.self] } + set { self[IsPlatformFocusSystemEnabled.self] = newValue } + } + + private struct IsFocusedKey: EnvironmentKey { + static var defaultValue: Bool { false } + } + + /// Returns whether the nearest focusable ancestor has focus. + /// + /// If there is no focusable ancestor, the value is `false`. + @available(OpenSwiftUI_v2_0, *) + public fileprivate(set) var isFocused: Bool { + get { self[IsFocusedKey.self] } + set { self[IsFocusedKey.self] = newValue } + } + + private struct FocusDisabledKey: EnvironmentKey { + static var defaultValue: Bool { false } + } + + var focusDisabled: Bool { + get { self[FocusDisabledKey.self] } + set { self[FocusDisabledKey.self] = newValue } + } +} + +// MARK: - EnvironmentValues + Focus Navigation + +extension EnvironmentValues { + private struct AllControlsNavigableKey: EnvironmentKey { + static var defaultValue: Bool { false } + } + + var allControlsNavigable: Bool { + get { self[AllControlsNavigableKey.self] } + set { self[AllControlsNavigableKey.self] = newValue } + } +} + +// MARK: - View + Focus (Deprecated) + +extension View { + /// Specifies if the view is focusable and, if so, adds an action to perform + /// when the view comes into focus. + /// + /// - Parameters: + /// - isFocusable: A Boolean value that indicates whether this view is + /// focusable. + /// - onFocusChange: A closure that's called whenever this view either + /// gains or loses focus. The Boolean parameter to `onFocusChange` is + /// `true` when the view is in focus; otherwise, it's `false`. + /// + /// - Returns: A view that sets whether a view is focusable, and triggers + /// `onFocusChange` when the view gains or loses focus. + @available(iOS, unavailable) + @available(OpenSwiftUI_v1_0, *) + @available(*, deprecated, message: "Use FocusState and View.focused(_:equals) for functionality previously provided by the onChange parameter.") + @available(visionOS, unavailable) + nonisolated public func focusable( + _ isFocusable: Bool = true, + onFocusChange: @escaping (_ isFocused: Bool) -> Void = { _ in } + ) -> some View { + let configuration: any _FocusableModifier_Configuration + if _SemanticFeature_v5.isEnabled { + configuration = FocusInteractions.automatic + } else { + configuration = FocusableOptions() + } + return modifier(_FocusableModifier( + isFocusable: isFocusable, + configuration: configuration, + onFocusChange: onFocusChange + )) + } + + @_spi(Private) + @available(OpenSwiftUI_v3_0, *) + @available(*, deprecated, message: "Use View.focusable(_:interactions:) instead.") + nonisolated public func focusable(_ isFocusable: Bool = true, options: FocusableOptions) -> some View { + modifier(_FocusableModifier( + isFocusable: isFocusable, + configuration: options, + onFocusChange: { _ in } + )) + } +} From 8c2c39fb361996f2b7f788fa43e131356d0faab8 Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 24 Sep 2026 21:36:43 +0800 Subject: [PATCH 04/22] feat(focus): implement focusable environment rules --- .../OpenSwiftUI/Event/Focus/Focusable.swift | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/Sources/OpenSwiftUI/Event/Focus/Focusable.swift b/Sources/OpenSwiftUI/Event/Focus/Focusable.swift index d43b93176..a332fe067 100644 --- a/Sources/OpenSwiftUI/Event/Focus/Focusable.swift +++ b/Sources/OpenSwiftUI/Event/Focus/Focusable.swift @@ -294,6 +294,57 @@ extension FocusableOptions: _FocusableModifier_Configuration { } } +// MARK: - FocusableOptionsKey + +struct FocusableOptionsKey: PreferenceKey { + static var defaultValue: FocusableOptions { [] } + + static func reduce(value: inout FocusableOptions, nextValue: () -> FocusableOptions) { + value.formUnion(nextValue()) + } +} + +// MARK: - IsFocusedEnvironmentChild + +struct IsFocusedEnvironmentChild: Rule { + @Attribute var item: FocusItem.ViewItem + @Attribute var environment: EnvironmentValues + #if os(iOS) || os(visionOS) + @Attribute var isPlatformItemFocused: Bool + #elseif os(macOS) + @OptionalAttribute var focusedItem: FocusItem?? + #endif + + var value: EnvironmentValues { + var env = environment + #if os(iOS) || os(visionOS) + if !item.options.contains(.platformContainerHandlesFocus) { + env.isFocused = isPlatformItemFocused + } + #elseif os(macOS) + if let focusedItem = focusedItem ?? nil, + case let .view(focusedView) = focusedItem.base { + env.isFocused = focusedView.id == item.id + } else { + env.isFocused = false + } + #endif + return env + } +} + +// MARK: - IOSFocusEnabledFlag + +#if os(iOS) || os(visionOS) +struct IOSFocusEnabledFlag: ViewInputBoolFlag { + static func evaluate(inputs: _GraphInputs) -> Bool { + inputs[Self.self] == value + && (inputs.interfaceIdiom.accepts(.pad) + || inputs.interfaceIdiom.accepts(.carPlay)) + } +} +#endif + // MARK: - EnvironmentValues + Focus extension EnvironmentValues { From 698def233d1d2fb640cf902c6cd17bcb836d2585 Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 24 Sep 2026 22:35:39 +0800 Subject: [PATCH 05/22] feat(focus): implement focus item update rules --- .../Event/Focus/FocusEffectDelegate.swift | 35 +++++ .../OpenSwiftUI/Event/Focus/Focusable.swift | 121 ++++++++++++++++++ Sources/OpenSwiftUI/View/IdentityLink.swift | 10 ++ 3 files changed, 166 insertions(+) create mode 100644 Sources/OpenSwiftUI/Event/Focus/FocusEffectDelegate.swift diff --git a/Sources/OpenSwiftUI/Event/Focus/FocusEffectDelegate.swift b/Sources/OpenSwiftUI/Event/Focus/FocusEffectDelegate.swift new file mode 100644 index 000000000..1199d024a --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Focus/FocusEffectDelegate.swift @@ -0,0 +1,35 @@ +// +// FocusEffectDelegate.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: WIP +// ID: 8C909BE7747C720C6B9D560863FD7609 (SwiftUI) + +#if os(macOS) +import OpenAttributeGraphShims +import OpenSwiftUICore + +// MARK: - EnvironmentValues + Focus Effect Delegation + +extension EnvironmentValues { + var delegatesFocusEffect: Bool { + get { self[DelegatesFocusEffectKey.self] } + set { self[DelegatesFocusEffectKey.self] = newValue } + } +} + +private struct DelegatesFocusEffectKey: EnvironmentKey { + static var defaultValue: Bool { false } +} + +extension CachedEnvironment.ID { + fileprivate static let delegatesFocusEffect: CachedEnvironment.ID = .init() +} + +extension _GraphInputs { + var delegatesFocusEffect: Attribute { + mapEnvironment(id: .delegatesFocusEffect) { $0.delegatesFocusEffect } + } +} +#endif diff --git a/Sources/OpenSwiftUI/Event/Focus/Focusable.swift b/Sources/OpenSwiftUI/Event/Focus/Focusable.swift index a332fe067..3af2288e5 100644 --- a/Sources/OpenSwiftUI/Event/Focus/Focusable.swift +++ b/Sources/OpenSwiftUI/Event/Focus/Focusable.swift @@ -345,6 +345,110 @@ struct IOSFocusEnabledFlag: ViewInputBoolFlag { } #endif +// MARK: - UpdateViewFocusItem + +private struct UpdateViewFocusItem: StatefulRule, ObservedAttribute { + @Attribute var modifier: _FocusableModifier + @Attribute var options: FocusableOptions + @Attribute var phase: ViewPhase + @Attribute var isEnabled: Bool + @Attribute var focusDisabled: Bool + #if os(macOS) + @OptionalAttribute var focusedItem: FocusItem?? + @Attribute var delegatesFocusEffect: Bool + #endif + weak var viewGraph: ViewGraph? = .current + var identityTracker: ViewIdentity.Tracker = .init() + var isFocused = false + + init( + modifier: Attribute<_FocusableModifier>, + options: Attribute, + inputs: _ViewInputs + ) { + _modifier = modifier + _options = options + _phase = inputs.viewPhase + _isEnabled = inputs.base.isEnabled + #if os(macOS) + _focusedItem = .init(inputs.focusedItem) + #endif + _focusDisabled = inputs.base.focusDisabled + #if os(macOS) + _delegatesFocusEffect = inputs.base.delegatesFocusEffect + #endif + } + + typealias Value = FocusItem.ViewItem + + mutating func updateValue() { + let (modifier, modifierChanged) = $modifier.changedValue() + let (options, optionsChanged) = $options.changedValue() + let (isEnabled, enabledChanged) = $isEnabled.changedValue() + #if os(macOS) + let (focusDisabled, disabledChanged) = $focusDisabled.changedValue() + let oldID = identityTracker.id + #else + _ = $focusDisabled.changedValue() + #endif + let (id, identityChanged) = identityTracker.update(for: phase) + #if os(macOS) + let (focusedItem, focusChanged) = _focusedItem.changedValue() ?? (nil, false) + if identityChanged || focusChanged || modifierChanged || optionsChanged || enabledChanged || disabledChanged || !hasValue { + if let focusedItem, case let .view(focusedView) = focusedItem.base { + if focusedView.id == id { + isFocused = true + if !modifier.isFocusable || focusDisabled { + viewGraph?[FocusViewGraph.self]?.pointee.needsFocusUpdate = true + } + } else { + isFocused = false + if focusedView.id == oldID { + viewGraph?[FocusViewGraph.self]?.pointee.needsFocusUpdate = true + } + } + } + } + #endif + if identityChanged || modifierChanged || optionsChanged || enabledChanged || !hasValue { + #if os(macOS) + value = .init( + id: id, + isFocusable: modifier.isFocusable && isEnabled, + options: options, + onFocusChange: modifier.onFocusChange, + delegatesFocusEffect: false + ) + #else + value = .init( + id: id, + isFocusable: modifier.isFocusable && isEnabled, + options: options, + onFocusChange: modifier.onFocusChange + ) + #endif + } + #if os(macOS) + value.delegatesFocusEffect = delegatesFocusEffect + #endif + } + + func destroy() { + _openSwiftUIEmptyStub() + } +} + +// MARK: - ResolvedOptions + +private struct ResolvedOptions: Rule { + @Attribute var modifier: _FocusableModifier + @Attribute var environment: EnvironmentValues + + var value: FocusableOptions { + modifier.configuration.resolve(in: environment) + } +} + // MARK: - EnvironmentValues + Focus extension EnvironmentValues { @@ -386,6 +490,23 @@ extension EnvironmentValues { } } +extension CachedEnvironment.ID { + static let focusDisabled: CachedEnvironment.ID = .init() + static let isPlatformFocusSystemEnabled: CachedEnvironment.ID = .init() + static let focusGroupID: CachedEnvironment.ID = .init() + static let evaluateDefaultFocus: CachedEnvironment.ID = .init() +} + +extension _GraphInputs { + var focusDisabled: Attribute { + mapEnvironment(id: .focusDisabled) { $0.focusDisabled } + } + + var isPlatformFocusSystemEnabled: Attribute { + mapEnvironment(id: .isPlatformFocusSystemEnabled) { $0.isPlatformFocusSystemEnabled } + } +} + // MARK: - EnvironmentValues + Focus Navigation extension EnvironmentValues { diff --git a/Sources/OpenSwiftUI/View/IdentityLink.swift b/Sources/OpenSwiftUI/View/IdentityLink.swift index 585280408..f5f49c986 100644 --- a/Sources/OpenSwiftUI/View/IdentityLink.swift +++ b/Sources/OpenSwiftUI/View/IdentityLink.swift @@ -34,6 +34,16 @@ package struct ViewIdentity: Hashable { package var id: ViewIdentity package var resetSeed: UInt32 + init(id: ViewIdentity, resetSeed: UInt32) { + self.id = id + self.resetSeed = resetSeed + } + + init() { + id = .invalid + resetSeed = .zero + } + package mutating func update( for phase: ViewPhase ) -> (value: ViewIdentity, changed: Bool) { From 4b8f557380f7c024289a4b5a98f413ee1eed53a1 Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 25 Sep 2026 01:00:24 +0800 Subject: [PATCH 06/22] feat(focus): implement focus effect delegation --- .../Event/Focus/FocusEffectDelegate.swift | 99 ++++++++++++++++++- .../OpenSwiftUI/Event/Focus/Focusable.swift | 18 ++++ 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/Sources/OpenSwiftUI/Event/Focus/FocusEffectDelegate.swift b/Sources/OpenSwiftUI/Event/Focus/FocusEffectDelegate.swift index 1199d024a..4a8d18171 100644 --- a/Sources/OpenSwiftUI/Event/Focus/FocusEffectDelegate.swift +++ b/Sources/OpenSwiftUI/Event/Focus/FocusEffectDelegate.swift @@ -3,15 +3,57 @@ // OpenSwiftUI // // Audited for 6.5.4 -// Status: WIP +// Status: Complete // ID: 8C909BE7747C720C6B9D560863FD7609 (SwiftUI) #if os(macOS) +import AppKit import OpenAttributeGraphShims +@_spi(ForOpenSwiftUIOnly) import OpenSwiftUICore +// MARK: - FocusEffectDelegateResponder + +class FocusEffectDelegateResponder: DefaultLayoutViewResponder, FocusResponder { + var focusAccessibilityNode: AccessibilityNode? + var keyPressHandlers: [KeyPress.Handler] = [] + weak var focusRingView: (NSView & FocusRingDelegate)? + + var platformItem: NSView? { _openSwiftUIUnreachableCode() } + var isEnabled: Bool { _openSwiftUIUnreachableCode() } + + var viewItem: FocusItem.ViewItem? { nil } + var frame: CGRect? { nil } + var isInVisibleRect: Bool { false } + var effectiveLayoutDirection: LayoutDirection? { nil } + + var firstKeyViewInSubtree: NSView? { + get { nil } + set { _openSwiftUIEmptyStub() } + } + + var lastKeyViewInSubtree: NSView? { + get { nil } + set { _openSwiftUIEmptyStub() } + } + + var prefersDefaultFocus: Bool { false } + var defaultFocusNamespace: Namespace.ID? { nil } + var focusItem: FocusItem? { nil } + var evaluateDefaultFocus: EvaluateDefaultFocusAction? { nil } + var delegatesFocusEffect: Bool { false } + + func setFocusRingView(_ view: (NSView & FocusRingDelegate)?) { + focusRingView = view + } +} + // MARK: - EnvironmentValues + Focus Effect Delegation +private struct DelegatesFocusEffectKey: EnvironmentKey { + static var defaultValue: Bool { false } +} + extension EnvironmentValues { var delegatesFocusEffect: Bool { get { self[DelegatesFocusEffectKey.self] } @@ -19,10 +61,6 @@ extension EnvironmentValues { } } -private struct DelegatesFocusEffectKey: EnvironmentKey { - static var defaultValue: Bool { false } -} - extension CachedEnvironment.ID { fileprivate static let delegatesFocusEffect: CachedEnvironment.ID = .init() } @@ -32,4 +70,55 @@ extension _GraphInputs { mapEnvironment(id: .delegatesFocusEffect) { $0.delegatesFocusEffect } } } + +// MARK: - FocusEffectDelegateModifier + +private struct FocusEffectDelegateModifier: MultiViewModifier, PrimitiveViewModifier { + static func _makeView( + modifier: _GraphValue, + inputs: _ViewInputs, + body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs + ) -> _ViewOutputs { + guard inputs.preferences.requiresViewResponders else { + return body(_Graph(), inputs) + } + var newInputs = inputs + newInputs.transform = Attribute(FocusCoordinateSpaceTransform( + transform: inputs.transform, + position: inputs.animatedPosition() + )) + let responder = FocusEffectDelegateResponder(inputs: newInputs) + let isEnabled = Attribute(value: !newInputs.archivedView.isArchived && !newInputs[UsingGraphicsRenderer.self]) + let focusEffect = Attribute(UpdateFocusEffectView( + isEnabled: isEnabled, + responder: responder + )) + var outputs = makeSecondaryLayerView( + secondaryLayer: focusEffect, + alignment: Attribute(value: Alignment.center), + inputs: newInputs, + body: body, + flipOrder: false + ) + outputs.preferences.viewResponders = Attribute(ResponderFilter( + children: outputs.viewResponders(), + responder: responder + )) + return outputs + } + + struct ResponderFilter: StatefulRule { + @Attribute var children: [ViewResponder] + let responder: FocusEffectDelegateResponder + + typealias Value = [ViewResponder] + + mutating func updateValue() { + responder.updateChildren($children.changedValue()) + if !hasValue { + value = [responder] + } + } + } +} #endif diff --git a/Sources/OpenSwiftUI/Event/Focus/Focusable.swift b/Sources/OpenSwiftUI/Event/Focus/Focusable.swift index 3af2288e5..c3303d969 100644 --- a/Sources/OpenSwiftUI/Event/Focus/Focusable.swift +++ b/Sources/OpenSwiftUI/Event/Focus/Focusable.swift @@ -333,6 +333,24 @@ struct IsFocusedEnvironmentChild: Rule { } } +// MARK: - FocusCoordinateSpaceTransform + +#if os(macOS) +let focusCoordinateSpace: CoordinateSpace.ID = .init() + +struct FocusCoordinateSpaceTransform: Rule { + @Attribute var transform: ViewTransform + @Attribute var position: CGPoint + + var value: ViewTransform { + var transform = transform + transform.appendPosition(position) + transform.appendCoordinateSpace(id: focusCoordinateSpace) + return transform + } +} +#endif + // MARK: - IOSFocusEnabledFlag #if os(iOS) || os(visionOS) From 366e016adba59929c9d1d26736d2d9d493f77531 Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 25 Sep 2026 13:38:46 +0800 Subject: [PATCH 07/22] feat(focus): implement macOS focus ring views --- .../Event/Focus/FocusEffectView.swift | 113 ++++++++++++++++++ .../Event/Focus/FocusRingDelegate.swift | 8 -- .../Event/Focus/FocusRingHelper.swift | 90 ++++++++++++++ .../OpenSwiftUI/Event/Focus/Focusable.swift | 12 ++ 4 files changed, 215 insertions(+), 8 deletions(-) create mode 100644 Sources/OpenSwiftUI/Event/Focus/FocusEffectView.swift delete mode 100644 Sources/OpenSwiftUI/Event/Focus/FocusRingDelegate.swift create mode 100644 Sources/OpenSwiftUI/Event/Focus/FocusRingHelper.swift diff --git a/Sources/OpenSwiftUI/Event/Focus/FocusEffectView.swift b/Sources/OpenSwiftUI/Event/Focus/FocusEffectView.swift new file mode 100644 index 000000000..64d8e1cd0 --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Focus/FocusEffectView.swift @@ -0,0 +1,113 @@ +// +// FocusEffectView.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: B9E8471D77A01696E0731B43A95A9346 (SwiftUI) + +#if os(macOS) +import AppKit +import COpenSwiftUI +import OpenAttributeGraphShims +@_spi(ForOpenSwiftUIOnly) +import OpenSwiftUICore + +// MARK: - _FocusRingView + +private class _FocusRingView: NSView, FocusRingDelegate { + var focused: Bool = false + weak var responder: ViewResponder? + final lazy var helper = FocusRingHelper(responder: responder, focusRingView: self) + + var canShowFocusRing: Bool { true } + + init(responder: ViewResponder) { + self.responder = responder + super.init(frame: .zero) + setFlipped(true) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var focusRingMaskBounds: NSRect { + helper.focusRingMaskBounds + } + + override func drawFocusRingMask() { + helper.drawFocusRingMask() + } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } +} + +extension _FocusRingView: CustomRecursiveStringConvertible { + var descriptionAttributes: [(name: String, value: String)] { + var attributes: [(name: String, value: String)] = [] + if let responder = responder as? any FocusResponder, + responder.delegatesFocusEffect { + attributes.append((name: "containerDefinesFocusEffect", value: "true")) + } + return attributes + } + + var defaultDescriptionAttributes: Set { + _TestApp.isIntending(to: .ignoreGeometry) ? [] : [.rect] + } +} + +// MARK: - UpdateFocusEffectView + +struct UpdateFocusEffectView: StatefulRule { + @Attribute var isEnabled: Bool + let responder: ViewResponder + + typealias Value = FocusEffectView + + mutating func updateValue() { + let (isEnabled, changed) = $isEnabled.changedValue() + if changed || !hasValue { + value = .init(responder: responder, shouldDisplay: isEnabled) + } + } +} + +// MARK: - FocusEffectView + +struct FocusEffectView: RendererLeafView, PlatformViewFactory { + let responder: ViewResponder + var shouldDisplay: Bool + + static func _makeView(view: _GraphValue, inputs: _ViewInputs) -> _ViewOutputs { + var newInputs = inputs + newInputs.preferences.requiresViewResponders = false + return makeLeafView(view: view, inputs: newInputs) + } + + func content() -> DisplayList.Content.Value { + shouldDisplay ? .platformView(self) : .color(.clear) + } + + func makePlatformView() -> AnyObject? { + let view = _FocusRingView(responder: responder) + (responder as? any FocusResponder)?.setFocusRingView(view) + return view + } + + func updatePlatformView(_ view: inout AnyObject) { + _openSwiftUIEmptyStub() + } + + func renderPlatformView( + in ctx: GraphicsContext, + size: CGSize, + renderer: DisplayList.GraphicsRenderer + ) { + _openSwiftUIEmptyStub() + } +} +#endif diff --git a/Sources/OpenSwiftUI/Event/Focus/FocusRingDelegate.swift b/Sources/OpenSwiftUI/Event/Focus/FocusRingDelegate.swift deleted file mode 100644 index b058bc03d..000000000 --- a/Sources/OpenSwiftUI/Event/Focus/FocusRingDelegate.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// FocusRingDelegate.swift -// OpenSwiftUI -// -// Audited for 6.5.4 -// Status: WIP - -protocol FocusRingDelegate: AnyObject {} diff --git a/Sources/OpenSwiftUI/Event/Focus/FocusRingHelper.swift b/Sources/OpenSwiftUI/Event/Focus/FocusRingHelper.swift new file mode 100644 index 000000000..4ab8e5091 --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Focus/FocusRingHelper.swift @@ -0,0 +1,90 @@ +// +// FocusRingHelper.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: 1E6F543B2B96E8B73646864D883C37DC (SwiftUI) + +#if os(macOS) +import AppKit +@_spi(ForOpenSwiftUIOnly) +import OpenSwiftUICore + +// MARK: - FocusRingHelper + +class FocusRingHelper { + weak var responder: ViewResponder? + weak var focusRingView: NSView? + private var _focusRingPath: Path? + + init(responder: ViewResponder?, focusRingView: NSView?) { + self.responder = responder + self.focusRingView = focusRingView + } + + var focusRingMaskBounds: CGRect { + resolvedFocusRingPath.boundingRect + } + + func drawFocusRingMask() { + let path = resolvedFocusRingPath.cgPath + if path.isEmpty { + focusRingView?.bounds.fill() + } else if let context = NSGraphicsContext.current?.cgContext { + context.addPath(path) + context.drawPath(using: .fill) + } + } + + private var resolvedFocusRingPath: Path { + if let _focusRingPath { + return _focusRingPath + } + var path = Path() + responder?.addContentPath( + to: &path, + kind: .focusEffect, + in: .id(focusCoordinateSpace), + observer: self + ) + if path.isEmpty { + responder?.addContentPath( + to: &path, + kind: .interaction, + in: .id(focusCoordinateSpace), + observer: self + ) + if !path.isContinuous { + path = Path(path.boundingRect) + } + } + _focusRingPath = path + return path + } +} + +extension FocusRingHelper: TrivialContentPathObserver { + func contentPathDidChange(for parent: ViewResponder) { + _focusRingPath = nil + focusRingView?.noteFocusRingMaskChanged() + } +} + +// MARK: - Path + isContinuous + +extension Path { + fileprivate var isContinuous: Bool { + var isContinuous = true + var hasDrawingElement = false + forEach { element in + if case .move = element { + isContinuous = !hasDrawingElement + } else { + hasDrawingElement = true + } + } + return isContinuous + } +} +#endif diff --git a/Sources/OpenSwiftUI/Event/Focus/Focusable.swift b/Sources/OpenSwiftUI/Event/Focus/Focusable.swift index c3303d969..1ba951a9d 100644 --- a/Sources/OpenSwiftUI/Event/Focus/Focusable.swift +++ b/Sources/OpenSwiftUI/Event/Focus/Focusable.swift @@ -294,6 +294,18 @@ extension FocusableOptions: _FocusableModifier_Configuration { } } +// TODO: - FocusableViewResponder + +#if os(macOS) +// MARK: - FocusRingDelegate + +protocol FocusRingDelegate: AnyObject { + var focused: Bool { get set } + + var canShowFocusRing: Bool { get } +} +#endif + // MARK: - FocusableOptionsKey struct FocusableOptionsKey: PreferenceKey { From 00253cf4f7e8c7faa94685ebcfef824167da065f Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 25 Sep 2026 16:53:52 +0800 Subject: [PATCH 08/22] feat(focus): implement and register focus view graph --- .../Event/Focus/FocusViewGraph.swift | 134 ++++++++++++++++++ .../Hosting/AppKit/View/NSHostingView.swift | 6 + .../Hosting/UIKit/View/UIHostingView.swift | 1 + 3 files changed, 141 insertions(+) create mode 100644 Sources/OpenSwiftUI/Event/Focus/FocusViewGraph.swift diff --git a/Sources/OpenSwiftUI/Event/Focus/FocusViewGraph.swift b/Sources/OpenSwiftUI/Event/Focus/FocusViewGraph.swift new file mode 100644 index 000000000..7bf567911 --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Focus/FocusViewGraph.swift @@ -0,0 +1,134 @@ +// +// FocusViewGraph.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: A452B616B2FA920F078225192036641B (SwiftUI) + +import OpenAttributeGraphShims +@_spi(ForOpenSwiftUIOnly) +import OpenSwiftUICore +#if os(iOS) || os(visionOS) +import UIKit +#endif + +// MARK: - Focus System Inputs + +extension _GraphInputs { + var isFocusSystemEnabled: Attribute? { + get { self[IsFocusSystemEnabledKey.self].attribute } + set { self[IsFocusSystemEnabledKey.self] = .init(newValue) } + } + + private struct IsFocusSystemEnabledKey: GraphInput { + static let defaultValue: OptionalAttribute = .init() + } +} + +// MARK: - FocusViewGraph + +struct FocusViewGraph: ViewGraphFeature { + @OptionalAttribute var focusedItem: FocusItem?? + @OptionalAttribute var focusedValues: FocusedValues? + @OptionalAttribute var focusStore: FocusStore? + @OptionalAttribute var isFocusSystemEnabled: Bool? + @Attribute var accessibilityFocusStore: AccessibilityFocusStore + @Attribute var accessibilityFocus: AccessibilityFocus + var needsFocusUpdate: Bool + var wasFocusSystemEnabled: Bool + var needsFocusSystemEnabledUpdate: Bool + + init(graph: ViewGraph) { + let oldSubgraph = Subgraph.current + Subgraph.current = graph.globalSubgraph + defer { Subgraph.current = oldSubgraph } + + if graph.requestedOutputs.contains(.focus) { + _focusedItem = .init(Attribute(value: nil)) + _focusedValues = .init(Attribute(value: .init())) + _focusStore = .init(Attribute(value: .init())) + #if os(macOS) + if CoreTesting.isRunning { + _isFocusSystemEnabled = .init(Attribute(value: true)) + } else { + _isFocusSystemEnabled = .init() + } + #else + _ = CoreTesting.isRunning + _isFocusSystemEnabled = .init(Attribute(value: false)) + #endif + } else { + _focusedItem = .init() + _focusedValues = .init() + _focusStore = .init() + _isFocusSystemEnabled = .init() + } + _accessibilityFocusStore = Attribute(value: .init()) + _accessibilityFocus = Attribute(value: .init()) + needsFocusUpdate = false + wasFocusSystemEnabled = false + needsFocusSystemEnabledUpdate = false + } + + mutating func modifyViewInputs(inputs: inout _ViewInputs, graph: ViewGraph) { + inputs.focusedItem = $focusedItem + inputs.focusedValues = $focusedValues + inputs.focusStore = $focusStore + inputs.base.isFocusSystemEnabled = $isFocusSystemEnabled + inputs.accessibilityFocusStore = $accessibilityFocusStore + inputs.accessibilityFocus = $accessibilityFocus + } + + mutating func needsUpdate(graph: ViewGraph) -> Bool { + #if os(macOS) + needsFocusUpdate + #elseif os(iOS) || os(visionOS) + let needsFocusUpdate = self.needsFocusUpdate + if let view = graph.delegate?.as(UIView.self) { + let isEnabled = UIFocusSystem.focusSystem(for: view) != nil + needsFocusSystemEnabledUpdate = isEnabled != wasFocusSystemEnabled + wasFocusSystemEnabled = isEnabled + } + return needsFocusUpdate || needsFocusSystemEnabledUpdate + #else + _openSwiftUIPlatformUnimplementedWarning() + return false + #endif + } + + mutating func update(graph: ViewGraph) { + if needsFocusUpdate { + needsFocusUpdate = false + graph.delegate?.as((any FocusHost).self)?.focusDidChange() + } + if needsFocusSystemEnabledUpdate, let attribute = $isFocusSystemEnabled { + needsFocusSystemEnabledUpdate = false + graph.asyncTransaction( + mutation: IsFocusSystemEnabledMutation( + attr: WeakAttribute(attribute), + value: wasFocusSystemEnabled + ), + style: .deferred + ) + } + } + + private struct IsFocusSystemEnabledMutation: GraphMutation { + var attr: WeakAttribute + var value: Bool + + func apply() { + attr.attribute?.value = value + } + + mutating func combine(with other: T) -> Bool { + guard let other = other as? IsFocusSystemEnabledMutation, + other.attr == attr else { + return false + } + value = other.value + return true + } + } +} diff --git a/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSHostingView.swift b/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSHostingView.swift index 4e5696227..5eaa231b6 100644 --- a/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSHostingView.swift +++ b/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSHostingView.swift @@ -275,6 +275,7 @@ open class NSHostingView: NSView, XcodeViewDebugDataProvider where Cont ) // TODO super.init(frame: .zero) + appendViewGraphFeatures() initializeViewGraph() // TODO clipsToBounds = false @@ -331,6 +332,11 @@ open class NSHostingView: NSView, XcodeViewDebugDataProvider where Cont } } + func appendViewGraphFeatures() { + viewGraph.append(feature: FocusViewGraph(graph: viewGraph)) + // TODO + } + /// The renderer configuration of the hosting view. public final var _rendererConfiguration: _RendererConfiguration { get { diff --git a/Sources/OpenSwiftUI/Integration/Hosting/UIKit/View/UIHostingView.swift b/Sources/OpenSwiftUI/Integration/Hosting/UIKit/View/UIHostingView.swift index a00919fe3..7beb61b49 100644 --- a/Sources/OpenSwiftUI/Integration/Hosting/UIKit/View/UIHostingView.swift +++ b/Sources/OpenSwiftUI/Integration/Hosting/UIKit/View/UIHostingView.swift @@ -177,6 +177,7 @@ open class _UIHostingView: UIView, XcodeViewDebugDataProvider where Con >.self, options: options ) + _base.viewGraph.append(feature: FocusViewGraph(graph: _base.viewGraph)) eventBridge = UIKitEventBindingBridge(eventBindingManager: _base.eventBindingManager) // TODO if _UIUpdateAdaptiveRateNeeded() { From 94b1631fb056e80be9f2cb20091c509fc267f8ff Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 5 Oct 2026 16:15:30 +0800 Subject: [PATCH 09/22] feat(focus): implement focus store --- .../COpenSwiftUI/Shims/UIKit/UIKit_Private.h | 1 + .../COpenSwiftUI/Shims/UIKit/UIKit_Private.m | 5 + .../OpenSwiftUI/Event/Focus/FocusStore.swift | 298 +++++++++++++++++- .../Event/Focus/FocusStoreLocation.swift | 35 -- 4 files changed, 292 insertions(+), 47 deletions(-) delete mode 100644 Sources/OpenSwiftUI/Event/Focus/FocusStoreLocation.swift diff --git a/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.h b/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.h index 355433873..f2900174d 100644 --- a/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.h +++ b/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.h @@ -42,6 +42,7 @@ OPENSWIFTUI_ASSUME_NONNULL_BEGIN @end @interface UIView (OpenSwiftUI_SPI) +@property(nonatomic, readonly, nullable) UIResponder *firstResponder_openswiftui_safe_wrapper OPENSWIFTUI_SWIFT_NAME(firstResponder); - (nullable id)_largeContentViewerItemAtPoint:(CGPoint)point OPENSWIFTUI_SWIFT_NAME(_largeContentViewerItem(at:)); - (instancetype)_initWithLayer:(CALayer *)layer; - (void)_geometryChanged:(const void *)geometry forAncestor:(nullable UIView *)ancestor; diff --git a/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.m b/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.m index a5771bf0a..0439c41f9 100644 --- a/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.m +++ b/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.m @@ -44,6 +44,11 @@ - (void)_saveRestorationUserActivityStateForScene_openswiftui_safe_wrapper:(UISc @end @implementation UIView (OpenSwiftUI_SPI) +- (UIResponder *)firstResponder_openswiftui_safe_wrapper { + OPENSWIFTUI_SAFE_WRAPPER_IMP(UIResponder *, @"firstResponder", nil); + return func(self, selector); +} + - (BOOL)_shouldAnimatePropertyWithKey_openswiftui_safe_wrapper:(NSString *)key { OPENSWIFTUI_SAFE_WRAPPER_IMP(BOOL, @"_shouldAnimatePropertyWithKey:", NO, NSString *); return func(self, selector, key); diff --git a/Sources/OpenSwiftUI/Event/Focus/FocusStore.swift b/Sources/OpenSwiftUI/Event/Focus/FocusStore.swift index 396a2ed1b..234ad289a 100644 --- a/Sources/OpenSwiftUI/Event/Focus/FocusStore.swift +++ b/Sources/OpenSwiftUI/Event/Focus/FocusStore.swift @@ -1,40 +1,314 @@ +// +// FocusStore.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: 5347FD43F24C67CC5D552D8EE95E9192 (SwiftUI) + +import COpenSwiftUI import OpenAttributeGraphShims @_spi(ForOpenSwiftUIOnly) import OpenSwiftUICore -package struct FocusStore { - package var seed: VersionSeed - package var focusedResponders: [WeakBox] - package var plists: [ObjectIdentifier: PropertyList] +// MARK: - FocusStoreLocation + +class FocusStoreLocation: AnyLocation { + var store: FocusStore + weak var host: GraphHost? + var resetValue: Value + var focusSeed: VersionSeed + var deferredUpdate: (Value, VersionSeed)? + var resolvedEntry: FocusStore.Entry? + var resolvedSeed: VersionSeed + var _wasRead: Bool + + var id: ObjectIdentifier { ObjectIdentifier(self) } + + override var wasRead: Bool { + get { + _wasRead + } + set { + _wasRead = newValue + } + } + + override func get() -> Value { + getValue(forReading: false) + } + + override func set(_ value: Value, transaction: Transaction) { + host?.asyncTransaction { [weak self, resetValue] in + guard let self else { return } + deferredUpdate = nil + if value == resetValue { + if getValue(forReading: false) != resetValue, let entry = resolvedEntry { + Update.enqueueAction(reason: nil) { + entry.updateFocus(false) + } + } + } else if let entry = findEntry(with: value) { + Update.enqueueAction(reason: nil) { + entry.updateFocus(true) + } + } else { + deferUpdate(value) + } + } + } + + override init() { _openSwiftUIBaseClassAbstractMethod() } + + init(host: GraphHost, resetValue: Value) { + self.store = FocusStore() + self.host = host + self.resetValue = resetValue + self.focusSeed = .empty + self.deferredUpdate = nil + self.resolvedEntry = nil + self.resolvedSeed = .empty + self._wasRead = false + super.init() + } + + func getValue(forReading: Bool) -> Value { + if GraphHost.isUpdating, forReading { + _wasRead = true + } + if resolvedEntry == nil || !store.seed.matches(resolvedSeed) { + resolvedEntry = findFocusedEntry() + resolvedSeed = store.seed + } + return resolvedEntry?.value ?? resetValue + } + + func findFocusedEntry() -> FocusStore.Entry? { + guard let plist = store.plists[id] else { return nil } + var result: FocusStore.Entry? + var focusedIndex = store.focusedResponders.count + plist.forEach(keyType: FocusStore.Key.self) { entry, stop in + guard let entry, entry.isValid else { return } + if let responder = entry.responder { + for (index, focused) in store.focusedResponders.enumerated() { + guard focused.base === responder, index < focusedIndex else { continue } + result = entry + focusedIndex = index + stop = index == 0 + return + } + } else if entry.searchFieldState?.wrappedValue.isFocused == true { + result = entry + stop = true + } + } + return result + } + + func findEntry(with value: Value) -> FocusStore.Entry? { + guard let plist = store.plists[id] else { return nil } + var result: FocusStore.Entry? + plist.forEach(keyType: FocusStore.Key.self) { entry, stop in + guard let entry, entry.isValid, value == entry.value else { return } + result = entry + stop = true + } + return result + } + + func deferUpdate(_ value: Value) { + deferredUpdate = (value, focusSeed) + } + + override func update() -> (Value, Bool) { + let oldValue = resolvedEntry?.value ?? resetValue + let value = getValue(forReading: false) + return (value, oldValue != value) + } + + func performDeferredUpdate() { + if let (value, seed) = deferredUpdate, + seed.matches(focusSeed) || seed.matches(.empty) { + set(value, transaction: .current) + } else { + deferredUpdate = nil + } + } +} + +extension FocusStoreLocation: Location {} + +// MARK: - FocusStore + +struct FocusStore: Equatable { + var seed: VersionSeed + var focusedResponders: [WeakBox] + var plists: [ObjectIdentifier: PropertyList] - package init() { + init() { self.seed = .empty self.focusedResponders = [] self.plists = [:] } -} -// MARK: - FocusStore.Key + static func == (lhs: FocusStore, rhs: FocusStore) -> Bool { + lhs.seed.matches(rhs.seed) + } -extension FocusStore { - struct Key: PropertyKey { - static var defaultValue: Entry? { nil } + init(_ list: FocusStoreList) { + self.init() + makeStoreContent(list) + } + + private mutating func makeStoreContent(_ list: FocusStoreList) { + var version = DisplayList.Version() + for item in list.items { + version.combine(with: item.version) + } + seed = VersionSeed(value: UInt32(version.value)) + for item in list.items { + var plist = plists[item.propertyID] ?? PropertyList() + item.storeUpdateAction.update?(&plist) + plists[item.propertyID] = plist + if item.isFocused, let responder = item.responder as? ViewResponder { + focusedResponders.append(WeakBox(responder)) + } + } } } -// MARK: - FocusStore.Item +// MARK: - FocusStore.Entry + extension FocusStore { struct Entry { - + var value: Value + var focusScopes: [Namespace.ID] + private var target: Target + + private enum Target { + case focusResponder(WeakBox, WeakBox) + case searchField(Binding) + } + + init( + value: Value, + focusScopes: [Namespace.ID], + responder: any BaseFocusResponder, + bridge: FocusBridge + ) { + self.init( + value: value, + focusScopes: focusScopes, + target: .focusResponder(WeakBox(responder as? ViewResponder), WeakBox(bridge)) + ) + } + + private init(value: Value, focusScopes: [Namespace.ID], target: Target) { + self.value = value + self.focusScopes = focusScopes + self.target = target + } + + init(value: Value, focusScopes: [Namespace.ID], searchFieldState: Binding) { + self.init(value: value, focusScopes: focusScopes, target: .searchField(searchFieldState)) + } + + var responder: (any BaseFocusResponder)? { + guard case let .focusResponder(responder, _) = target else { return nil } + return responder.base as? any BaseFocusResponder + } + + var searchFieldState: Binding? { + guard case let .searchField(state) = target else { return nil } + return state + } + + var isValid: Bool { + switch target { + case let .focusResponder(_, bridge): + bridge.base?.canAcceptFocus ?? false + case .searchField: + true + } + } + + private var defaultFocusItem: FocusItem? { + guard case let .focusResponder(responder, bridge) = target, + let responder = responder.base as? any BaseFocusResponder + else { return nil } + #if os(macOS) + return bridge.base?.navigator.defaultFocusItem(for: responder) + #elseif os(iOS) || os(visionOS) + // Resolve the host's responder graph before visiting the target subtree. + _ = bridge.base?.host?.responderNode + var result: FocusItem? + responder.visitBaseFocusResponders { responder in + if let responder = responder as? any FocusResponder, + responder.focusItem?.isFocusable == true { + result = responder.focusItem + } else if let item = responder.platformItem as? any UIKitHostedContainerFocusItem, + item.providesDefaultFocusItems { + result = FocusItem( + base: .platformItem(WeakBox(item)), + prefersFocusSystem: false, + responder: nil + ) + } + return result == nil ? .next : .cancel + } + return result + #else + _openSwiftUIUnimplementedFailure() + #endif + } + + fileprivate func updateFocus(_ isFocused: Bool) { + switch target { + case let .focusResponder(_, bridge): + if isFocused { + _ = defaultFocusItem.map { item -> Void in + bridge.base?.moveFocus(to: item, designatedPlatformResponder: nil) + } + } else { + #if os(macOS) + bridge.base?.host?.window?.makeFirstResponder(nil) + #elseif os(iOS) || os(visionOS) + bridge.base?.host?.firstResponder?.resignFirstResponder() + #else + _openSwiftUIUnimplementedFailure() + #endif + } + case let .searchField(binding): + var state = binding.wrappedValue + state.focusUpdate = SearchFocusUpdate(value: isFocused) + if isFocused, case .notSearching = state.searchState { + state.searchState = .searching(state.hasSuggestions ?? state.hasCustomAccessory ?? false) + state.isFocused = true + } + binding.wrappedValue = state + } + } } } +// MARK: - FocusStoreInputKey + struct FocusStoreInputKey: ViewInput { static var defaultValue: OptionalAttribute { .init() } } +// MARK: - FocusStore.Key + +extension FocusStore { + struct Key: PropertyKey { + static var defaultValue: Entry? { nil } + } +} + +// MARK: - Focus Store Inputs + extension _ViewInputs { var focusStore: Attribute? { get { base.focusStore } diff --git a/Sources/OpenSwiftUI/Event/Focus/FocusStoreLocation.swift b/Sources/OpenSwiftUI/Event/Focus/FocusStoreLocation.swift deleted file mode 100644 index fafae7338..000000000 --- a/Sources/OpenSwiftUI/Event/Focus/FocusStoreLocation.swift +++ /dev/null @@ -1,35 +0,0 @@ -@_spi(ForOpenSwiftUIOnly) import OpenSwiftUICore - -class FocusStoreLocation: AnyLocation, @unchecked Sendable { - override var wasRead: Bool { - get { - _wasRead - } - set { - _wasRead = newValue - } - } - - override func get() -> A { - _openSwiftUIUnimplementedFailure() - } - - override func set(_ value: A, transaction: Transaction) { - _openSwiftUIUnimplementedFailure() - } - - typealias Value = A - - override init() { _openSwiftUIBaseClassAbstractMethod() } - - var store: FocusStore - weak var host: GraphHost? - var resetValue: A - var focusSeed: VersionSeed - var failedAssignment: (A, VersionSeed)? - var resolvedEntry: FocusStore.Entry? - var resolvedSeed: VersionSeed - var _wasRead: Bool - - var id: ObjectIdentifier { ObjectIdentifier(self) } -} From 9f9afaa120068151a412ba12ec9c7c6b0ea318ba Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 5 Oct 2026 16:31:43 +0800 Subject: [PATCH 10/22] feat(focus): implement focus store list --- .../Event/Focus/FocusStoreList.swift | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 Sources/OpenSwiftUI/Event/Focus/FocusStoreList.swift diff --git a/Sources/OpenSwiftUI/Event/Focus/FocusStoreList.swift b/Sources/OpenSwiftUI/Event/Focus/FocusStoreList.swift new file mode 100644 index 000000000..7b2b47e87 --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Focus/FocusStoreList.swift @@ -0,0 +1,116 @@ +// +// FocusStoreList.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +@_spi(ForOpenSwiftUIOnly) +@_spi(Private) +import OpenSwiftUICore + +// MARK: - FocusStoreList + +struct FocusStoreList: Equatable { + var items: [Item] = [] + + struct Key: HostPreferenceKey { + static var defaultValue: FocusStoreList { .init() } + + static func reduce(value: inout FocusStoreList, nextValue: () -> FocusStoreList) { + value.append(contentsOf: nextValue()) + } + } + + struct Item { + var version: DisplayList.Version + var propertyID: ObjectIdentifier + var bindingUpdateAction: FocusStateBindingUpdateAction + var storeUpdateAction: FocusStoreUpdateAction + weak var responder: ResponderNode? + weak var bridge: FocusBridge? + var isFocused: Bool + } + + static func == (lhs: FocusStoreList, rhs: FocusStoreList) -> Bool { + let lhsVersion = lhs.items.reduce(into: DisplayList.Version()) { + $0.combine(with: $1.version) + } + let rhsVersion = rhs.items.reduce(into: DisplayList.Version()) { + $0.combine(with: $1.version) + } + return lhsVersion == rhsVersion + } +} + +// MARK: - FocusStoreList + Collection + +extension FocusStoreList: Collection { + var startIndex: Int { items.startIndex } + + var endIndex: Int { items.endIndex } + + subscript(index: Int) -> Item { + _read { yield items[index] } + } + + func index(after index: Int) -> Int { + index + 1 + } +} + +// MARK: - FocusStoreList + RangeReplaceableCollection + +extension FocusStoreList: RangeReplaceableCollection { + mutating func replaceSubrange(_ subrange: Range, with newElements: C) + where C: Collection, C.Element == Item { + items.replaceSubrange(subrange, with: newElements) + } +} + +// MARK: - FocusStoreUpdateAction + +struct FocusStoreUpdateAction { + let update: ((inout PropertyList) -> Void)? + + init( + value: Value, + responder: any BaseFocusResponder, + bridge: FocusBridge?, + focusScopes: [Namespace.ID] + ) { + update = { [weak responder, weak bridge] plist in + guard let responder, let bridge else { return } + plist[FocusStore.Key.self] = .init( + value: value, + focusScopes: focusScopes, + responder: responder, + bridge: bridge + ) + } + } + + init( + value: Value, + focusScopes: [Namespace.ID], + searchFieldState: Binding + ) { + update = { plist in + plist[FocusStore.Key.self] = .init( + value: value, + focusScopes: focusScopes, + searchFieldState: searchFieldState + ) + } + } +} + +// MARK: - FocusStateBindingUpdateAction + +struct FocusStateBindingUpdateAction { + let update: () -> Void + + init(binding: FocusState.Binding, value: Value) { + update = { binding.wrappedValue = value } + } +} From 90c297950718f507df1197a15453c07a7acbfe5b Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 5 Oct 2026 16:34:06 +0800 Subject: [PATCH 11/22] feat: add search field state api --- .../Event/Focus/SearchFieldState.swift | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 Sources/OpenSwiftUI/Event/Focus/SearchFieldState.swift diff --git a/Sources/OpenSwiftUI/Event/Focus/SearchFieldState.swift b/Sources/OpenSwiftUI/Event/Focus/SearchFieldState.swift new file mode 100644 index 000000000..b2aeecdb5 --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Focus/SearchFieldState.swift @@ -0,0 +1,58 @@ +// +// SearchFieldState.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Empty + +import Foundation +@_spi(ForOpenSwiftUIOnly) +import OpenSwiftUICore + +struct SearchFieldState { + var searchState: SearchState + var suggestionPlacement: SearchSuggestionsPlacement.Role + var hasSuggestions: Bool? + var hasCustomAccessory: Bool? + var selection: ViewIdentity? + var text: AttributedString + var isFocused: Bool + var focusUpdate: SearchFocusUpdate +} + +enum SearchState { + case searching(Bool) + case notSearching +} + +struct SearchSuggestionsPlacement { + var role: Role + + enum Role { + case menu + case content + } +} + +struct SearchFocusUpdate: Equatable { + var seed: VersionSeed + var value: Bool + + private static var nextSeed: UInt32 = 0 + static var empty: SearchFocusUpdate = .init(seed: .empty, value: false) + + init(value: Bool) { + self.seed = VersionSeed(value: Self.nextSeed) + self.value = value + Self.nextSeed &+= 1 + } + + private init(seed: VersionSeed, value: Bool) { + self.seed = seed + self.value = value + } + + static func == (lhs: SearchFocusUpdate, rhs: SearchFocusUpdate) -> Bool { + lhs.seed.matches(rhs.seed) + } +} From 5f686e7a0492120b76c6c1803bf3911261207ae9 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 5 Oct 2026 16:36:43 +0800 Subject: [PATCH 12/22] feat: add focus bridge api --- .../OpenSwiftUI/Event/Focus/FocusBridge.swift | 68 +++++++++++++++++++ .../Event/Focus/FocusNavigator.swift | 20 ++++++ 2 files changed, 88 insertions(+) create mode 100644 Sources/OpenSwiftUI/Event/Focus/FocusBridge.swift create mode 100644 Sources/OpenSwiftUI/Event/Focus/FocusNavigator.swift diff --git a/Sources/OpenSwiftUI/Event/Focus/FocusBridge.swift b/Sources/OpenSwiftUI/Event/Focus/FocusBridge.swift new file mode 100644 index 000000000..7d0131de1 --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Focus/FocusBridge.swift @@ -0,0 +1,68 @@ +// +// FocusBridge.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: WIP + +@_spi(ForOpenSwiftUIOnly) +import OpenSwiftUICore + +// MARK: - FocusHost + +protocol FocusHost: ViewRendererHost { + var focusedItem: FocusItem? { get } + var focusedValues: FocusedValues { get set } + var currentAccessibilityFocusStore: AccessibilityFocusStore { get set } + + func focus(item: FocusItem) + func focusDidChange() +} + +// MARK: - FocusBridge [TBA] + +class FocusBridge { + var flags: Flags + + #if os(macOS) + lazy var navigator: FocusNavigator = .init(bridge: self) + #endif + + // MARK: - Initialization [TBA] + + init() { + _openSwiftUIUnimplementedFailure() + } + + // MARK: - Platform focus [TBA] + + var host: (PlatformView & FocusBridgeProvider & FocusHost)? { + _openSwiftUIUnimplementedFailure() + } + + func moveFocus(to item: FocusItem, designatedPlatformResponder: PlatformView?) { + _openSwiftUIUnimplementedFailure() + } + + var canAcceptFocus: Bool { + #if os(macOS) + flags.contains(.canAcceptFocus) + #else + _openSwiftUIUnimplementedFailure() + #endif + } + + // MARK: - Flags + + struct Flags: OptionSet { + var rawValue: Int + + static let canAcceptFocus: Flags = .init(rawValue: 1 << 1) + } +} + +// MARK: - FocusBridgeProvider + +protocol FocusBridgeProvider: AnyObject { + var focusBridge: FocusBridge { get } +} diff --git a/Sources/OpenSwiftUI/Event/Focus/FocusNavigator.swift b/Sources/OpenSwiftUI/Event/Focus/FocusNavigator.swift new file mode 100644 index 000000000..798fb16be --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Focus/FocusNavigator.swift @@ -0,0 +1,20 @@ +// +// FocusNavigator.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: WIP + +#if os(macOS) +import OpenSwiftUICore + +struct FocusNavigator { + weak var bridge: FocusBridge? + + // MARK: - Navigation [TBA] + + func defaultFocusItem(for responder: any BaseFocusResponder) -> FocusItem? { + _openSwiftUIUnimplementedFailure() + } +} +#endif From a891be20752ef8c6a20a91d1e54438b265578b23 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 5 Oct 2026 16:55:44 +0800 Subject: [PATCH 13/22] feat(focus): implement focus item behavior --- .../OpenSwiftUI/Event/Focus/FocusItem.swift | 279 +++++++++++++++++- 1 file changed, 277 insertions(+), 2 deletions(-) diff --git a/Sources/OpenSwiftUI/Event/Focus/FocusItem.swift b/Sources/OpenSwiftUI/Event/Focus/FocusItem.swift index 6277d53df..baf46ec51 100644 --- a/Sources/OpenSwiftUI/Event/Focus/FocusItem.swift +++ b/Sources/OpenSwiftUI/Event/Focus/FocusItem.swift @@ -3,7 +3,282 @@ // OpenSwiftUI // // Audited for 6.5.4 -// Status: WIP +// Status: Complete // ID: 25A477FB55B8969CF1C407C6BC07980E (SwiftUI) -struct FocusItem {} +import COpenSwiftUI +import OpenAttributeGraphShims +@_spi(ForOpenSwiftUIOnly) +import OpenSwiftUICore +#if os(iOS) || os(visionOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +// MARK: - FocusItem + +struct FocusItem: Equatable { + var base: Base + var prefersFocusSystem: Bool + #if os(iOS) || os(visionOS) || os(macOS) + weak var responder: (any FocusResponder)? + #else + weak var responder: (any BaseFocusResponder)? + #endif + var seed: VersionSeed = .empty + + enum Base { + case view(ViewItem) + #if os(iOS) || os(visionOS) + case platformItem(WeakBox) + #else + case platformItem(WeakBox) + #endif + case platformResponder(WeakBox) + } + + struct ViewItem { + var id: ViewIdentity + var isFocusable: Bool + var options: FocusableOptions + var onFocusChange: (Bool) -> Void + #if os(macOS) + var delegatesFocusEffect: Bool + #endif + } + + var isFocusable: Bool { + switch base { + case let .view(item): + return item.isFocusable + case let .platformItem(item): + #if os(iOS) || os(visionOS) + return item.base?.canBecomeFocused ?? false + #elseif os(macOS) + return false + #else + _openSwiftUIUnimplementedFailure() + #endif + case let .platformResponder(item): + #if os(iOS) || os(visionOS) + if let responder = responder as? UIViewResponder { + return responder.wantsOpenSwiftUIFocusItem + } + guard let view = item.base else { return false } + if _SemanticFeature_v5.isEnabled { + return view.firstFocusableDescendant != nil + } else { + return view.canBecomeFirstResponder + } + #elseif os(macOS) + return item.base?.firstFocusableDescendant != nil + #else + _openSwiftUIUnimplementedFailure() + #endif + } + } + + var isExpired: Bool { + switch base { + case .view: + false + case let .platformItem(item): + item.base == nil + case let .platformResponder(item): + item.base == nil + } + } + + #if os(iOS) || os(visionOS) + var platformResponder: PlatformView? { + if case let .platformResponder(item) = base { + return item.base + } + guard case let .platformItem(item) = base, let item = item.base else { + return nil + } + if prefersFocusSystem, UIFocusSystem.focusSystem(for: item) != nil { + return nil + } + return item as? UIView + } + #endif + + #if os(macOS) + var isNavigable: Bool { + switch base { + case let .view(item): + item.isFocusable && receivesFocusFromKeyboard + case .platformItem: + false + case let .platformResponder(item): + item.base?.firstFocusableDescendant != nil && receivesFocusFromKeyboard + } + } + + func focusDidChange(isFocused: Bool) { + if case let .view(item) = base { + item.onFocusChange(isFocused) + } + guard isFocused, let node = responder?.focusAccessibilityNode else { return } + let element: PlatformAccessibilityElement? + if node.impliedVisibility(consideringParent: true, with: nil) == .hidden { + element = NSAccessibility.unignoredAncestor(of: node) as? PlatformAccessibilityElement + } else { + element = node + } + if let element { + NSAccessibility.post( + element: element.knownRepresentedElement, + notification: .focusedUIElementChanged + ) + } + } + + var receivesFocusFromMouse: Bool { + switch base { + case let .view(item): + item.options.contains(.fromMouse) + case .platformItem: + false + case let .platformResponder(item): + item.base?.needsPanelToBecomeKey ?? false + } + } + + var receivesFocusFromKeyboard: Bool { + switch base { + case let .view(item): + return item.options.contains(.fromKeyboard) + case .platformItem: + return false + case let .platformResponder(item): + guard let view = item.base else { return false } + return view.canBecomeKeyView || view.firstFocusableDescendant != nil + } + } + + var platformItemDrawsFocusRingMask: Bool { + switch base { + case let .view(item): + return item.options.contains(.platformItemDrawsFocusRingMask) + case .platformItem: + return false + case let .platformResponder(item): + guard let view = item.base else { return false } + return view.focusRingType != .none + } + } + #endif + + func hasEqualIdentity(to other: FocusItem) -> Bool { + switch (base, other.base) { + case let (.view(lhs), .view(rhs)): + lhs.id == rhs.id + case let (.platformItem(lhs), .platformItem(rhs)): + lhs.base === rhs.base + case let (.platformResponder(lhs), .platformResponder(rhs)): + lhs.base === rhs.base + default: + false + } + } + + static func isFocusChange(from oldItem: FocusItem?, to newItem: FocusItem?) -> Bool { + switch (oldItem, newItem) { + case (nil, nil): + false + case let (oldItem?, newItem?): + !oldItem.hasEqualIdentity(to: newItem) + default: + true + } + } + + static func == (lhs: FocusItem, rhs: FocusItem) -> Bool { + false + } +} + +// MARK: - PlatformView + Focus + +extension PlatformView { + var firstFocusableDescendant: PlatformView? { + #if os(iOS) || os(visionOS) + PlatformSubtreeIterator(stack: [[self]]).first { $0.canBecomeFirstResponder } + #elseif os(macOS) + PlatformSubtreeIterator(stack: [[self]]).first { view in + let customizing = view as? any RecursiveIgnoreHitTestCustomizing + return view.acceptsFirstResponder && view.canBecomeKeyView + && !view.ignoreHitTest && !(customizing?.recursiveIgnoreHitTest ?? false) + } + #else + _openSwiftUIUnimplementedFailure() + #endif + } +} + +// MARK: - PlatformSubtreeIterator + +private struct PlatformSubtreeIterator: Sequence, IteratorProtocol { + var stack: [[PlatformView]] + var count: Int = 0 + + mutating func next() -> PlatformView? { + #if os(iOS) || os(visionOS) || os(macOS) + #if os(iOS) || os(visionOS) + guard count < 10 else { return nil } + #else + guard count < Int.max else { return nil } + #endif + guard var views = stack.popLast() else { return nil } + let view = views.removeFirst() + defer { count += 1 } + #if os(iOS) || os(visionOS) + if view is UIScrollView, !(view is UITextView) { + stack = [] + return nil + } + #endif + if !views.isEmpty { + stack.append(views) + } + if !view.subviews.isEmpty { + #if os(iOS) || os(visionOS) + guard stack.count < 4 else { return view } + let layoutDirection = view.effectiveUserInterfaceLayoutDirection + #else + let layoutDirection = view.userInterfaceLayoutDirection + #endif + let subviews = layoutDirection == .rightToLeft + ? Array(view.subviews.reversed()) + : view.subviews + stack.append(subviews) + } + return view + #else + _openSwiftUIUnimplementedFailure() + #endif + } +} + +// MARK: - FocusedItemInputKey + +private struct FocusedItemInputKey: ViewInput { + static var defaultValue: OptionalAttribute { .init() } +} + +extension _ViewInputs { + var focusedItem: Attribute? { + get { base.focusedItem } + set { base.focusedItem = newValue } + } +} + +extension _GraphInputs { + var focusedItem: Attribute? { + get { self[FocusedItemInputKey.self].attribute } + set { self[FocusedItemInputKey.self] = .init(newValue) } + } +} From 3cda2a12e0352a7cdb35bb0144b6308fffebf917 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 5 Oct 2026 17:23:38 +0800 Subject: [PATCH 14/22] feat(focus): implement shared focus responder interfaces --- .../OpenSwiftUI/Event/Focus/FocusItem.swift | 10 +- .../Event/Focus/FocusResponder.swift | 120 ++++++++++++++++++ 2 files changed, 121 insertions(+), 9 deletions(-) create mode 100644 Sources/OpenSwiftUI/Event/Focus/FocusResponder.swift diff --git a/Sources/OpenSwiftUI/Event/Focus/FocusItem.swift b/Sources/OpenSwiftUI/Event/Focus/FocusItem.swift index baf46ec51..ad8156ba8 100644 --- a/Sources/OpenSwiftUI/Event/Focus/FocusItem.swift +++ b/Sources/OpenSwiftUI/Event/Focus/FocusItem.swift @@ -21,20 +21,12 @@ import AppKit struct FocusItem: Equatable { var base: Base var prefersFocusSystem: Bool - #if os(iOS) || os(visionOS) || os(macOS) weak var responder: (any FocusResponder)? - #else - weak var responder: (any BaseFocusResponder)? - #endif var seed: VersionSeed = .empty enum Base { case view(ViewItem) - #if os(iOS) || os(visionOS) - case platformItem(WeakBox) - #else - case platformItem(WeakBox) - #endif + case platformItem(WeakBox) case platformResponder(WeakBox) } diff --git a/Sources/OpenSwiftUI/Event/Focus/FocusResponder.swift b/Sources/OpenSwiftUI/Event/Focus/FocusResponder.swift new file mode 100644 index 000000000..1c0b204ed --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Focus/FocusResponder.swift @@ -0,0 +1,120 @@ +// +// FocusResponder.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +import Foundation +@_spi(ForOpenSwiftUIOnly) +import OpenSwiftUICore + +#if os(iOS) || os(visionOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +#if os(iOS) || os(visionOS) +typealias PlatformFocusItem = any UIFocusItem +#else +typealias PlatformFocusItem = PlatformView +#endif + +// MARK: - BaseFocusResponder + +protocol BaseFocusResponder: ResponderNode { + var platformItem: PlatformFocusItem? { get } + var viewItem: FocusItem.ViewItem? { get } + var frame: CGRect? { get } + var isInVisibleRect: Bool { get } + var isEnabled: Bool { get } + #if os(macOS) + var effectiveLayoutDirection: LayoutDirection? { get } + var firstKeyViewInSubtree: NSView? { get set } + var lastKeyViewInSubtree: NSView? { get set } + #endif + var prefersDefaultFocus: Bool { get } + var defaultFocusNamespace: Namespace.ID? { get } + #if os(iOS) || os(visionOS) + var focusGroupID: FocusGroupIdentifier? { get } + #endif +} + +extension BaseFocusResponder { + var isInVisibleRect: Bool { + #if os(macOS) + frame != nil + #elseif os(iOS) || os(visionOS) + // Deleted in the target image. + _openSwiftUIUnreachableCode() + #else + _openSwiftUIUnimplementedFailure() + #endif + } + + var prefersDefaultFocus: Bool { false } + + var defaultFocusNamespace: Namespace.ID? { nil } +} + +// MARK: - FocusResponder + +protocol FocusResponder: BaseFocusResponder { + var focusItem: FocusItem? { get } + var focusAccessibilityNode: AccessibilityNode? { get set } + var keyPressHandlers: [KeyPress.Handler] { get set } + #if os(macOS) + var evaluateDefaultFocus: EvaluateDefaultFocusAction? { get } + var focusRingView: (NSView & FocusRingDelegate)? { get } + var delegatesFocusEffect: Bool { get } + func setFocusRingView(_ view: (NSView & FocusRingDelegate)?) + #endif +} + +extension FocusResponder { + var platformItem: PlatformFocusItem? { + guard let focusItem, case let .platformItem(item) = focusItem.base else { return nil } + return item.base + } + + var viewItem: FocusItem.ViewItem? { + #if os(macOS) + guard let focusItem, case let .view(item) = focusItem.base else { return nil } + return item + #elseif os(iOS) || os(visionOS) + // Deleted in the target image. + _openSwiftUIUnreachableCode() + #else + _openSwiftUIUnimplementedFailure() + #endif + } +} + +// MARK: - ResponderNode + Focus + +extension ResponderNode { + func visitFocusResponders(applying visitor: (any FocusResponder) -> ResponderVisitorResult) { + visit { responder in + guard let responder = responder as? any FocusResponder else { return .next } + return visitor(responder) + } + } + + func visitBaseFocusResponders(applying visitor: (any BaseFocusResponder) -> ResponderVisitorResult) { + visit { responder in + guard let responder = responder as? any BaseFocusResponder else { return .next } + return visitor(responder) + } + } +} + +#if os(iOS) || os(visionOS) +extension UIViewResponder { + // These witnesses are deleted in the target image. + var frame: CGRect? { _openSwiftUIUnreachableCode() } + var focusGroupID: FocusGroupIdentifier? { _openSwiftUIUnreachableCode() } + + var isEnabled: Bool { true } +} +#endif From 957a556d0a3d92d8f31049ba16728383a97db37d Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 5 Oct 2026 17:27:54 +0800 Subject: [PATCH 15/22] feat(focus): implement and document focused values --- .../Event/Focus/FocusedValue.swift | 243 ++++++++++++++++++ .../Event/Focus/FocusedValueKey.swift | 123 --------- 2 files changed, 243 insertions(+), 123 deletions(-) create mode 100644 Sources/OpenSwiftUI/Event/Focus/FocusedValue.swift delete mode 100644 Sources/OpenSwiftUI/Event/Focus/FocusedValueKey.swift diff --git a/Sources/OpenSwiftUI/Event/Focus/FocusedValue.swift b/Sources/OpenSwiftUI/Event/Focus/FocusedValue.swift new file mode 100644 index 000000000..51b8eec8d --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Focus/FocusedValue.swift @@ -0,0 +1,243 @@ +// +// FocusedValue.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: B1EDB9B13C46E98882FF0F0DFA69F617 (SwiftUI) + +import OpenSwiftUICore +import OpenAttributeGraphShims + +// MARK: - FocusedValue + +/// A property wrapper for observing values from the focused view or one of its +/// ancestors. +/// +/// If multiple views publish values using the same key, the wrapped property +/// will reflect the value from the view closest to focus. +@available(OpenSwiftUI_v2_0, *) +@propertyWrapper +public struct FocusedValue: DynamicProperty { + @usableFromInline + @frozen + internal enum Content { + case keyPath(KeyPath) + case value(Value?) + } + + @usableFromInline + internal var content: FocusedValue.Content + + /// A new property wrapper for the given key path. + /// + /// The value of the property wrapper is updated dynamically as focus + /// changes and different published values go in and out of scope. + /// + /// - Parameter keyPath: The key path for the focus value to read. + public init(_ keyPath: KeyPath) { + content = .keyPath(keyPath) + } + + /// The value for the focus key given the current scope and state of the + /// focused view hierarchy. + /// + /// Returns `nil` when nothing in the focused view hierarchy exports a + /// value. + @inlinable + public var wrappedValue: Value? { + if case .value(let value) = content { + return value + } else { + return nil + } + } + + public static func _makeProperty( + in buffer: inout _DynamicPropertyBuffer, + container: _GraphValue, + fieldOffset: Int, + inputs: inout _GraphInputs + ) { + let box = FocusedValueBox( + environment: inputs.environment, + focusedValues: inputs[FocusedValuesInputKey.self] + ) + buffer.append(box, fieldOffset: fieldOffset) + } +} + +@available(*, unavailable) +extension FocusedValue: Sendable {} + +@available(*, unavailable) +extension FocusedValue.Content: Sendable {} + +// MARK: - FocusedValueKey + +/// A protocol for identifier types used when publishing and observing focused +/// values. +/// +/// Unlike ``EnvironmentKey``, `FocusedValueKey` has no default value +/// requirement, because the default value for a key is always `nil`. +@available(OpenSwiftUI_v2_0, *) +public protocol FocusedValueKey { + associatedtype Value +} + +// MARK: - FocusedValues + +/// A collection of state exported by the focused view and its ancestors. +@available(OpenSwiftUI_v2_0, *) +public struct FocusedValues { + var plist: PropertyList + + var storageOptions: FocusedValues.StorageOptions + + var navigationDepth: Int + + var seed: VersionSeed + + struct StorageOptions: OptionSet { + let rawValue: UInt8 + + static let inFocusedViewHierarchy = StorageOptions(rawValue: 1 << 0) + static let scene = StorageOptions(rawValue: 1 << 1) + } + + @usableFromInline + internal init() { + plist = PropertyList() + storageOptions = [] + navigationDepth = -1 + seed = .empty + } + + /// Reads and writes values associated with a given focused value key. + public subscript(key: Key.Type) -> Key.Value? where Key: FocusedValueKey { + get { + var sceneEntry: Entry? + var sceneDepth = Int.min + var viewEntry: Entry? + plist.forEach(keyType: FocusedValuePropertyKey.self) { entry, _ in + guard let entry else { + return + } + if entry.scope == .scene, sceneEntry == nil || entry.depth > sceneDepth { + sceneEntry = entry + sceneDepth = entry.depth + } else if entry.scope == .view, entry.inFocusedViewHierarchy { + viewEntry = entry + } + } + return viewEntry?.value ?? sceneEntry?.value + } + set { + guard let newValue else { + return + } + let isSceneValue = storageOptions.contains(.scene) + plist[FocusedValuePropertyKey.self] = Entry( + scope: isSceneValue ? .scene : .view, + value: newValue, + inFocusedViewHierarchy: storageOptions.contains(.inFocusedViewHierarchy), + depth: isSceneValue ? navigationDepth : -1 + ) + } + } +} + +@available(*, unavailable) +extension FocusedValues: Sendable {} + +@available(OpenSwiftUI_v3_0, *) +extension FocusedValues: Equatable { + public static func == (lhs: FocusedValues, rhs: FocusedValues) -> Bool { + lhs.seed.matches(rhs.seed) + } +} + +// MARK: - FocusedValuePropertyKey + +private struct FocusedValuePropertyKey: PropertyKey where Key: FocusedValueKey { + static var defaultValue: FocusedValues.Entry? { nil } +} + +extension FocusedValues { + fileprivate struct Entry where Key: FocusedValueKey { + let scope: FocusedValueScope + let value: Key.Value + let inFocusedViewHierarchy: Bool + let depth: Int + } +} + +// MARK: - FocusedValueBox + +private struct FocusedValueBox: DynamicPropertyBox { + @Attribute var environment: EnvironmentValues + @OptionalAttribute var focusedValues: FocusedValues? + var keyPath: KeyPath? + var value: Value? + + typealias Property = FocusedValue + + mutating func update(property: inout Property, phase _: ViewPhase) -> Bool { + guard case let .keyPath(propertyKeyPath) = property.content else { + return false + } + let (focusedValues, focusedValuesChanged) = $focusedValues?.changedValue() ?? (.init(), false) + let (_, environmentChanged) = $environment.changedValue() + let keyPathChanged = propertyKeyPath != keyPath + if keyPathChanged { + keyPath = propertyKeyPath + } + var valueChanged = keyPathChanged || focusedValuesChanged || environmentChanged + if valueChanged { + let newValue = focusedValues[keyPath: propertyKeyPath] + if let value, compareValues(value, newValue) { + valueChanged = false + } else { + value = newValue + } + } + property.content = .value(value) + return valueChanged + } +} + +// MARK: - FocusedValuesInputKey + +struct FocusedValuesInputKey: ViewInput { + static var defaultValue: OptionalAttribute { + .init() + } +} + +extension _ViewInputs { + var focusedValues: Attribute? { + get { base.focusedValues } + set { base.focusedValues = newValue } + } +} + +extension _GraphInputs { + var focusedValues: Attribute? { + get { self[FocusedValuesInputKey.self].attribute } + set { self[FocusedValuesInputKey.self] = .init(newValue) } + } +} + +// MARK: - FocusedValueScope + +struct FocusedValueScope: Equatable, Identifiable { + let id: ViewIdentity + let name: String + + static let scene = FocusedValueScope(id: .init(), name: "Scene") + static let view = FocusedValueScope(id: .init(), name: "View") + + static func == (lhs: FocusedValueScope, rhs: FocusedValueScope) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Sources/OpenSwiftUI/Event/Focus/FocusedValueKey.swift b/Sources/OpenSwiftUI/Event/Focus/FocusedValueKey.swift deleted file mode 100644 index 51f43c3b1..000000000 --- a/Sources/OpenSwiftUI/Event/Focus/FocusedValueKey.swift +++ /dev/null @@ -1,123 +0,0 @@ -// -// FocusedValueKey.swift -// OpenSwiftUI -// -// Audited for 6.5.4 -// Status: WIP - -import OpenSwiftUICore -import OpenAttributeGraphShims - -@available(OpenSwiftUI_v2_0, *) -@propertyWrapper -public struct FocusedValue: DynamicProperty { - @usableFromInline - @frozen - internal enum Content { - case keyPath(KeyPath) - case value(Value?) - } - - @usableFromInline - internal var content: FocusedValue.Content - - public init(_ keyPath: KeyPath) { - _openSwiftUIUnimplementedFailure() - } - - @inlinable - public var wrappedValue: Value? { - if case .value(let value) = content { - return value - } else { - return nil - } - } - - public static func _makeProperty( - in buffer: inout _DynamicPropertyBuffer, - container: _GraphValue, - fieldOffset: Int, - inputs: inout _GraphInputs - ) { - _openSwiftUIUnimplementedFailure() - } -} - -@available(*, unavailable) -extension FocusedValue: Sendable {} - -@available(*, unavailable) -extension FocusedValue.Content: Sendable {} - -/// A protocol for identifier types used when publishing and observing focused -/// values. -/// -/// Unlike ``EnvironmentKey``, `FocusedValueKey` has no default value -/// requirement, because the default value for a key is always `nil`. -@available(OpenSwiftUI_v2_0, *) -public protocol FocusedValueKey { - associatedtype Value -} - -/// A collection of state exported by the focused view and its ancestors. -@available(OpenSwiftUI_v2_0, *) -public struct FocusedValues { - var plist: PropertyList - - struct StorageOptions: OptionSet { - let rawValue: UInt8 - } - - var storageOptions: FocusedValues.StorageOptions - - var navigationDepth: Int - - var seed: VersionSeed - - @usableFromInline - internal init() { - plist = PropertyList() - storageOptions = [] - navigationDepth = -1 - seed = .empty - } - - /// Reads and writes values associated with a given focused value key. - - public subscript(key: Key.Type) -> Key.Value? where Key: FocusedValueKey { - get { _openSwiftUIUnimplementedFailure() } - set { _openSwiftUIUnimplementedFailure() } - } -} - -@available(*, unavailable) -extension FocusedValues: Sendable {} - -@available(OpenSwiftUI_v3_0, *) -extension FocusedValues: Equatable { - public static func == (lhs: FocusedValues, rhs: FocusedValues) -> Bool { - lhs.seed.matches(rhs.seed) - } -} - -// FIXME -struct FocusedValuesInputKey: ViewInput { - static var defaultValue: OptionalAttribute { - .init() - } -} - -extension _ViewInputs { - var focusedValues: Attribute? { - get { base.focusedValues } - set { base.focusedValues = newValue } - } -} - -extension _GraphInputs { - var focusedValues: Attribute? { - get { self[FocusedValuesInputKey.self].attribute } - set { self[FocusedValuesInputKey.self] = .init(newValue) } - } -} From 0760c35582e712038b0996f85a10312d50a5f590 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 5 Oct 2026 17:55:06 +0800 Subject: [PATCH 16/22] feat(focus): implement focused bindings and value publishers --- .../Event/Focus/FocusedValue.swift | 414 +++++++++++++++++- .../Responder/ResponderViewModifier.swift | 45 ++ .../View/Navigation/NavigationAuthority.swift | 23 + 3 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 Sources/OpenSwiftUI/Event/Responder/ResponderViewModifier.swift create mode 100644 Sources/OpenSwiftUI/View/Navigation/NavigationAuthority.swift diff --git a/Sources/OpenSwiftUI/Event/Focus/FocusedValue.swift b/Sources/OpenSwiftUI/Event/Focus/FocusedValue.swift index 51b8eec8d..475e63392 100644 --- a/Sources/OpenSwiftUI/Event/Focus/FocusedValue.swift +++ b/Sources/OpenSwiftUI/Event/Focus/FocusedValue.swift @@ -6,8 +6,10 @@ // Status: Complete // ID: B1EDB9B13C46E98882FF0F0DFA69F617 (SwiftUI) -import OpenSwiftUICore import OpenAttributeGraphShims +@_spi(ForOpenSwiftUIOnly) +@_spi(Private) +import OpenSwiftUICore // MARK: - FocusedValue @@ -73,6 +75,283 @@ extension FocusedValue: Sendable {} @available(*, unavailable) extension FocusedValue.Content: Sendable {} +// MARK: - FocusedBinding + +/// A convenience property wrapper for observing and automatically unwrapping +/// state bindings from the focused view or one of its ancestors. +/// +/// If multiple views publish bindings using the same key, the wrapped property +/// will reflect the value of the binding from the view closest to focus. +@available(OpenSwiftUI_v2_0, *) +@propertyWrapper +public struct FocusedBinding: DynamicProperty { + @usableFromInline + @frozen + internal enum Content { + case keyPath(KeyPath?>) + case value(Binding?) + } + + @usableFromInline + internal var content: FocusedBinding.Content + + /// A new property wrapper for the given key path. + /// + /// The value of the property wrapper is updated dynamically as focus + /// changes and different published bindings go in and out of scope. + /// + /// - Parameter keyPath: The key path for the focus value to read. + public init(_ keyPath: KeyPath?>) { + content = .keyPath(keyPath) + } + + /// The unwrapped value for the focus key given the current scope and state + /// of the focused view hierarchy. + @inlinable + public var wrappedValue: Value? { + get { + if case .value(let value) = content { + return value?.wrappedValue + } else { + return nil + } + } + nonmutating set { + if case .value(let value) = content, let newValue = newValue { + value?.wrappedValue = newValue + } + } + } + + /// A binding to the optional value. + /// + /// The unwrapped value is `nil` when no focused view hierarchy has + /// published a corresponding binding. + public var projectedValue: Binding { + if case let .value(value?) = content { + return Binding(value) + } else { + return .constant(nil) + } + } + + public static func _makeProperty( + in buffer: inout _DynamicPropertyBuffer, + container: _GraphValue, + fieldOffset: Int, + inputs: inout _GraphInputs + ) { + // Content has the same layout as FocusedValue>.Content. + let box = FocusedValueBox>( + environment: inputs.environment, + focusedValues: inputs[FocusedValuesInputKey.self] + ) + buffer.append(box, fieldOffset: fieldOffset) + } +} + +@available(*, unavailable) +extension FocusedBinding: Sendable {} + +@available(*, unavailable) +extension FocusedBinding.Content: Sendable {} + +// MARK: - View + Focused Values + +extension View { + + /// Modifies this view by injecting a value that you provide for use by + /// other views whose state depends on the focused view hierarchy. + /// + /// - Parameters: + /// - keyPath: The key path to associate `value` with when adding + /// it to the existing table of exported focus values. + /// - value: The focus value to export. + /// - Returns: A modified representation of this view. + @available(OpenSwiftUI_v2_0, *) + nonisolated public func focusedValue( + _ keyPath: WritableKeyPath, + _ value: Value + ) -> some View { + modifier(ResponderViewModifier { responder in + FocusedValueModifier( + keyPath: keyPath, + value: .some(.some(value)), + responder: responder, + isSceneValue: false + ) + }) + } + + /// Creates a new view that exposes the provided value to other views whose + /// state depends on the focused view hierarchy. + /// + /// Use this method instead of ``View/focusedSceneValue(_:_:)`` when your + /// scene includes multiple focusable views with their own associated + /// values, and you need an app- or scene-scoped element like a command or + /// toolbar item that operates on the value associated with whichever view + /// currently has focus. Each focusable view can supply its own value. + /// + /// - Parameters: + /// - keyPath: The key path to associate `value` with when adding + /// it to the existing table of exported focus values. + /// - value: The focus value to export, or `nil` if no value is + /// currently available. + /// - Returns: A modified representation of this view. + @available(OpenSwiftUI_v4_0, *) + nonisolated public func focusedValue( + _ keyPath: WritableKeyPath, + _ value: Value? + ) -> some View { + modifier(ResponderViewModifier { responder in + FocusedValueModifier( + keyPath: keyPath, + value: .some(value), + responder: responder, + isSceneValue: false + ) + }) + } + + /// Modifies this view by injecting a value that you provide for use by + /// other views whose state depends on the focused scene. + /// + /// Use this method instead of ``View/focusedValue(_:_:)`` for values that + /// must be visible regardless of where focus is located in the active + /// scene. For example, if an app needs a command for moving focus to a + /// particular text field in the sidebar, it could use this modifier to + /// publish a button action that's visible to command views as long as the + /// scene is active, and regardless of where focus happens to be in it. + /// + /// struct Sidebar: View { + /// @FocusState var isFiltering: Bool + /// + /// var body: some View { + /// VStack { + /// TextField(...) + /// .focused($isFiltering) + /// .focusedSceneValue(\.filterAction) { + /// isFiltering = true + /// } + /// } + /// } + /// } + /// + /// struct NavigationCommands: Commands { + /// @FocusedValue(\.filterAction) var filterAction + /// + /// var body: some Commands { + /// CommandMenu("Navigate") { + /// Button("Filter in Sidebar") { + /// filterAction?() + /// } + /// } + /// .disabled(filterAction == nil) + /// } + /// } + /// + /// struct FilterActionKey: FocusedValueKey { + /// typealias Value = () -> Void + /// } + /// + /// extension FocusedValues { + /// var filterAction: (() -> Void)? { + /// get { self[FilterActionKey.self] } + /// set { self[FilterActionKey.self] = newValue } + /// } + /// } + /// + /// - Parameters: + /// - keyPath: The key path to associate `value` with when adding + /// it to the existing table of published focus values. + /// - value: The focus value to publish. + /// - Returns: A modified representation of this view. + @available(OpenSwiftUI_v3_0, *) + nonisolated public func focusedSceneValue( + _ keyPath: WritableKeyPath, + _ value: T + ) -> some View { + modifier(ResponderViewModifier { responder in + FocusedValueModifier( + keyPath: keyPath, + value: .some(.some(value)), + responder: responder, + isSceneValue: true + ) + }) + } + + /// Creates a new view that exposes the provided value to other views whose + /// state depends on the active scene. + /// + /// Use this method instead of ``View/focusedValue(_:_:)`` for values that + /// must be visible regardless of where focus is located in the active + /// scene. For example, if an app needs a command for moving focus to a + /// particular text field in the sidebar, it could use this modifier to + /// publish a button action that's visible to command views as long as the + /// scene is active, and regardless of where focus happens to be in it. + /// + /// struct Sidebar: View { + /// @FocusState var isFiltering: Bool + /// + /// var body: some View { + /// VStack { + /// TextField(...) + /// .focused($isFiltering) + /// .focusedSceneValue(\.filterAction) { + /// isFiltering = true + /// } + /// } + /// } + /// } + /// + /// struct NavigationCommands: Commands { + /// @FocusedValue(\.filterAction) var filterAction + /// + /// var body: some Commands { + /// CommandMenu("Navigate") { + /// Button("Filter in Sidebar") { + /// filterAction?() + /// } + /// } + /// .disabled(filterAction == nil) + /// } + /// } + /// + /// struct FilterActionKey: FocusedValueKey { + /// typealias Value = () -> Void + /// } + /// + /// extension FocusedValues { + /// var filterAction: (() -> Void)? { + /// get { self[FilterActionKey.self] } + /// set { self[FilterActionKey.self] = newValue } + /// } + /// } + /// + /// - Parameters: + /// - keyPath: The key path to associate `value` with when adding + /// it to the existing table of published focus values. + /// - value: The focus value to publish, or `nil` if no value is + /// currently available. + /// - Returns: A modified representation of this view. + @available(OpenSwiftUI_v4_0, *) + nonisolated public func focusedSceneValue( + _ keyPath: WritableKeyPath, + _ value: T? + ) -> some View { + modifier(ResponderViewModifier { responder in + FocusedValueModifier( + keyPath: keyPath, + value: .some(value), + responder: responder, + isSceneValue: true + ) + }) + } + +} + // MARK: - FocusedValueKey /// A protocol for identifier types used when publishing and observing focused @@ -172,6 +451,45 @@ extension FocusedValues { } } +// MARK: - FocusedValueModifier + +private struct FocusedValueModifier: MultiViewModifier, PrimitiveViewModifier { + let keyPath: WritableKeyPath + let value: Value? + let responder: ResponderNode + var isSceneValue: Bool + + static func _makeView( + modifier: _GraphValue, + inputs: _ViewInputs, + body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs + ) -> _ViewOutputs { + var outputs = body(_Graph(), inputs) + outputs.preferences.makePreferenceTransformer( + inputs: inputs.preferences, + key: FocusedValueList.Key.self, + transform: Attribute(Transform( + viewPhase: inputs.viewPhase, + modifier: modifier.value, + depth: inputs[NavigationAuthority.DepthKey.self], + focusItem: .init(inputs.focusedItem), + updateSeed: GraphHost.currentHost.data.$updateSeed + )) + ) + return outputs + } +} + +// MARK: - FocusedValueList.Item + +extension FocusedValueList { + struct Item { + var version: DisplayList.Version + var isFocused: Bool + var update: (inout FocusedValues) -> Void + } +} + // MARK: - FocusedValueBox private struct FocusedValueBox: DynamicPropertyBox { @@ -228,6 +546,100 @@ extension _GraphInputs { } } +// MARK: - FocusedValueList + +struct FocusedValueList { + var items: [Item] = [] + + var version: DisplayList.Version { + items.reduce(DisplayList.Version()) { max($0, $1.version) } + } +} + +// MARK: - FocusedValueModifier.Transform + +extension FocusedValueModifier { + struct Transform: StatefulRule { + @Attribute var viewPhase: ViewPhase + @Attribute var modifier: FocusedValueModifier + @Attribute var depth: Int + @OptionalAttribute var focusItem: FocusItem?? + @Attribute var updateSeed: UInt32 + var resetSeed: UInt32? + var content: Content? + var isFocused: Bool = false + var lastUpdateSeed: UInt32 = .max + var ttl: UInt32 = 0 + + typealias Value = (inout FocusedValueList) -> Void + + mutating func updateValue() { + let (modifier, modifierChanged) = $modifier.changedValue() + var changed = resetSeed != viewPhase.resetSeed + if changed { + resetSeed = viewPhase.resetSeed + lastUpdateSeed = 0 + ttl = 0 + } + if lastUpdateSeed != updateSeed { + lastUpdateSeed = updateSeed + ttl = 2 + } else { + if ttl != 0 { + ttl -= 1 + } + if ttl == 0, hasValue { + return + } + } + if modifierChanged, content == nil || !compareValues(content, modifier.value) { + content = modifier.value + changed = true + } + let isFocused = (focusItem ?? nil)?.responder?.isDescendant(of: modifier.responder) ?? false + if self.isFocused != isFocused { + self.isFocused = isFocused + changed = true + } + guard changed || !hasValue else { + return + } + let version = DisplayList.Version(forUpdate: ()) + let depth = depth + let item = FocusedValueList.Item(version: version, isFocused: isFocused) { values in + guard let value = modifier.value else { + return + } + var options: FocusedValues.StorageOptions = [] + if modifier.isSceneValue { + values.navigationDepth = depth + options.insert(.scene) + } + if isFocused { + options.insert(.inFocusedViewHierarchy) + } + values.storageOptions = options + values[keyPath: modifier.keyPath] = value + } + value = { list in + list.items.append(item) + } + } + } +} + +// MARK: - FocusedValueList.Key + +extension FocusedValueList { + struct Key: HostPreferenceKey { + static var defaultValue: FocusedValueList { .init() } + + static func reduce(value: inout FocusedValueList, nextValue: () -> FocusedValueList) { + value.items.append(contentsOf: nextValue().items) + } + } +} + // MARK: - FocusedValueScope struct FocusedValueScope: Equatable, Identifiable { diff --git a/Sources/OpenSwiftUI/Event/Responder/ResponderViewModifier.swift b/Sources/OpenSwiftUI/Event/Responder/ResponderViewModifier.swift new file mode 100644 index 000000000..659ce060e --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Responder/ResponderViewModifier.swift @@ -0,0 +1,45 @@ +// +// ResponderViewModifier.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: CBE01018B6DA97C826725A0E8E913C80 (SwiftUI) + +import OpenAttributeGraphShims +@_spi(ForOpenSwiftUIOnly) +import OpenSwiftUICore + +// MARK: - ResponderViewModifier + +struct ResponderViewModifier: MultiViewModifier, PrimitiveViewModifier where Content: ViewModifier { + var content: (ResponderNode) -> Content + + static func _makeView( + modifier: _GraphValue, + inputs: _ViewInputs, + body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs + ) -> _ViewOutputs { + let responder = DefaultLayoutViewResponder(inputs: inputs) + let child = _GraphValue(ResponderChild(modifier: modifier.value, responder: responder)) + var outputs = Content.makeDebuggableView(modifier: child, inputs: inputs, body: body) + if inputs.preferences.requiresViewResponders { + outputs.preferences.viewResponders = Attribute(DefaultLayoutResponderFilter( + children: outputs.viewResponders(), + responder: responder + )) + } + return outputs + } +} + +// MARK: - ResponderChild + +private struct ResponderChild: Rule where Content: ViewModifier { + @Attribute var modifier: ResponderViewModifier + let responder: DefaultLayoutViewResponder + + var value: Content { + modifier.content(responder) + } +} diff --git a/Sources/OpenSwiftUI/View/Navigation/NavigationAuthority.swift b/Sources/OpenSwiftUI/View/Navigation/NavigationAuthority.swift new file mode 100644 index 000000000..c5a9b5033 --- /dev/null +++ b/Sources/OpenSwiftUI/View/Navigation/NavigationAuthority.swift @@ -0,0 +1,23 @@ +// +// NavigationAuthority.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: WIP + +import OpenAttributeGraphShims +import OpenSwiftUICore + +// MARK: - NavigationAuthority [TBA] + +struct NavigationAuthority {} + +// MARK: - NavigationAuthority.DepthKey + +extension NavigationAuthority { + struct DepthKey: ViewInput { + static var defaultValue: Attribute { + Attribute(value: -1) + } + } +} From ad1ff3ae2f1a93b8271edbee484a79d36f78faf1 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 5 Oct 2026 18:11:47 +0800 Subject: [PATCH 17/22] chore: update focus state binding --- .../Accessibility/AccessibilityFocusState.swift | 7 ++++--- Sources/OpenSwiftUI/Event/Focus/FocusState.swift | 12 ++++++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Sources/OpenSwiftUI/Accessibility/AccessibilityFocusState.swift b/Sources/OpenSwiftUI/Accessibility/AccessibilityFocusState.swift index 1ceaf97d2..80aa7e026 100644 --- a/Sources/OpenSwiftUI/Accessibility/AccessibilityFocusState.swift +++ b/Sources/OpenSwiftUI/Accessibility/AccessibilityFocusState.swift @@ -74,7 +74,8 @@ public struct AccessibilityFocusState: DynamicProperty where Value: Hasha @propertyWrapper @frozen public struct Binding { - private var _binding: OpenSwiftUICore.Binding + @OpenSwiftUICore.Binding + private var binding: Value init(binding: OpenSwiftUICore.Binding) { _binding = binding @@ -82,8 +83,8 @@ public struct AccessibilityFocusState: DynamicProperty where Value: Hasha /// The underlying value referenced by the bound property. public var wrappedValue: Value { - get { _binding.wrappedValue } - nonmutating set { _binding.wrappedValue = newValue } + get { binding } + nonmutating set { binding = newValue } } /// The currently focused element. diff --git a/Sources/OpenSwiftUI/Event/Focus/FocusState.swift b/Sources/OpenSwiftUI/Event/Focus/FocusState.swift index 37b3e762e..a3b36b2b3 100644 --- a/Sources/OpenSwiftUI/Event/Focus/FocusState.swift +++ b/Sources/OpenSwiftUI/Event/Focus/FocusState.swift @@ -4,9 +4,12 @@ // // Audited for 3.5.2 // Status: WIP -// ID: 274D264A38B51DC68ACC48A91353B7D0 +// ID: 274D264A38B51DC68ACC48A91353B7D0 (SwiftUI) -@_spi(ForOpenSwiftUIOnly) public import OpenSwiftUICore +@_spi(ForOpenSwiftUIOnly) +public import OpenSwiftUICore + +// MARK: - FocusState [TBA] @frozen @propertyWrapper @@ -14,10 +17,10 @@ public struct FocusState: DynamicProperty where Value: Hashable { @frozen @propertyWrapper public struct Binding { - @OpenSwiftUI.Binding + @OpenSwiftUICore.Binding private var binding: Value - init(binding: OpenSwiftUI.Binding) { + init(binding: OpenSwiftUICore.Binding) { _binding = binding } @@ -44,6 +47,7 @@ public struct FocusState: DynamicProperty where Value: Hashable { var value: Value var location: AnyLocation? var resetValue: Value + public var wrappedValue: Value { get { getValue(forReading: true) From b46b195592e7e9500d0dd2594dabb4ff8bb0a046 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 5 Oct 2026 18:37:35 +0800 Subject: [PATCH 18/22] feat(focus): implement and document focus state --- .../Event/Focus/DefaultFocus.swift | 27 + .../OpenSwiftUI/Event/Focus/FocusState.swift | 589 +++++++++++++++++- 2 files changed, 603 insertions(+), 13 deletions(-) create mode 100644 Sources/OpenSwiftUI/Event/Focus/DefaultFocus.swift diff --git a/Sources/OpenSwiftUI/Event/Focus/DefaultFocus.swift b/Sources/OpenSwiftUI/Event/Focus/DefaultFocus.swift new file mode 100644 index 000000000..a64b2af0d --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Focus/DefaultFocus.swift @@ -0,0 +1,27 @@ +// +// DefaultFocus.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: WIP +// ID: 1F8B69996BE941D510140AD6558D8844 (SwiftUI) + +import OpenAttributeGraphShims +@_spi(ForOpenSwiftUIOnly) +import OpenSwiftUICore + +// MARK: - FocusScopesKey + +struct FocusScopesKey: EnvironmentKey { + static var defaultValue: [Namespace.ID] { [] } +} + +extension CachedEnvironment.ID { + fileprivate static let focusScopes: CachedEnvironment.ID = .init() +} + +extension _GraphInputs { + var focusScopes: Attribute<[Namespace.ID]> { + mapEnvironment(id: .focusScopes) { $0[FocusScopesKey.self] } + } +} diff --git a/Sources/OpenSwiftUI/Event/Focus/FocusState.swift b/Sources/OpenSwiftUI/Event/Focus/FocusState.swift index a3b36b2b3..337490ce2 100644 --- a/Sources/OpenSwiftUI/Event/Focus/FocusState.swift +++ b/Sources/OpenSwiftUI/Event/Focus/FocusState.swift @@ -2,37 +2,149 @@ // FocusState.swift // OpenSwiftUI // -// Audited for 3.5.2 +// Audited for 6.5.4 // Status: WIP // ID: 274D264A38B51DC68ACC48A91353B7D0 (SwiftUI) +import Foundation +import OpenAttributeGraphShims @_spi(ForOpenSwiftUIOnly) public import OpenSwiftUICore -// MARK: - FocusState [TBA] +// MARK: - FocusState +/// A property wrapper type that can read and write a value that OpenSwiftUI updates +/// as the placement of focus within the scene changes. +/// +/// Use this property wrapper in conjunction with ``View/focused(_:equals:)`` +/// and ``View/focused(_:)`` to +/// describe views whose appearance and contents relate to the location of +/// focus in the scene. When focus enters the modified view, the wrapped value +/// of this property updates to match a given prototype value. Similarly, when +/// focus leaves, the wrapped value of this property resets to `nil` +/// or `false`. Setting the property's value programmatically has the reverse +/// effect, causing focus to move to the view associated with the +/// updated value. +/// +/// In the following example of a simple login screen, when the user presses the +/// Sign In button and one of the fields is still empty, focus moves to that +/// field. Otherwise, the sign-in process proceeds. +/// +/// struct LoginForm: View { +/// enum Field: Hashable { +/// case username +/// case password +/// } +/// +/// @State private var username = "" +/// @State private var password = "" +/// @FocusState private var focusedField: Field? +/// +/// var body: some View { +/// Form { +/// TextField("Username", text: $username) +/// .focused($focusedField, equals: .username) +/// +/// SecureField("Password", text: $password) +/// .focused($focusedField, equals: .password) +/// +/// Button("Sign In") { +/// if username.isEmpty { +/// focusedField = .username +/// } else if password.isEmpty { +/// focusedField = .password +/// } else { +/// handleLogin(username, password) +/// } +/// } +/// } +/// } +/// } +/// +/// To allow for cases where focus is completely absent from a view tree, the +/// wrapped value must be either an optional or a Boolean. Set the focus binding +/// to `false` or `nil` as appropriate to remove focus from all bound fields. +/// You can also use this to remove focus from a ``TextField`` and thereby +/// dismiss the keyboard. +/// +/// ### Avoid ambiguous focus bindings +/// +/// The same view can have multiple focus bindings. In the following example, +/// setting `focusedField` to either `name` or `fullName` causes the field +/// to receive focus: +/// +/// struct ContentView: View { +/// enum Field: Hashable { +/// case name +/// case fullName +/// } +/// @FocusState private var focusedField: Field? +/// +/// var body: some View { +/// TextField("Full Name", ...) +/// .focused($focusedField, equals: .name) +/// .focused($focusedField, equals: .fullName) +/// } +/// } +/// +/// On the other hand, binding the same value to two views is ambiguous. In +/// the following example, two separate fields bind focus to the `name` value: +/// +/// struct ContentView: View { +/// enum Field: Hashable { +/// case name +/// case fullName +/// } +/// @FocusState private var focusedField: Field? +/// +/// var body: some View { +/// TextField("Name", ...) +/// .focused($focusedField, equals: .name) +/// TextField("Full Name", ...) +/// .focused($focusedField, equals: .name) // incorrect re-use of .name +/// } +/// } +/// +/// If the user moves focus to either field, the `focusedField` binding updates +/// to `name`. However, if the app programmatically sets the value to `name`, +/// OpenSwiftUI chooses the first candidate, which in this case is the "Name" +/// field. OpenSwiftUI also emits a runtime warning in this case, since the repeated +/// binding is likely a programmer error. +/// +@available(OpenSwiftUI_v3_0, *) @frozen @propertyWrapper public struct FocusState: DynamicProperty where Value: Hashable { + /// A property wrapper type that can read and write a value that indicates + /// the current focus location. @frozen @propertyWrapper public struct Binding { @OpenSwiftUICore.Binding private var binding: Value - + init(binding: OpenSwiftUICore.Binding) { _binding = binding } - + + /// The underlying value referenced by the bound property. public var wrappedValue: Value { get { binding } nonmutating set { binding = newValue } } + /// A projection of the binding value that returns a binding. + /// + /// Use the projected value to pass a binding value down a view + /// hierarchy. public var projectedValue: FocusState.Binding { self } - + + var location: FocusStoreLocation { + _binding.location as! FocusStoreLocation + } + var propertyID: ObjectIdentifier { if let location = _binding.location as? FocusStoreLocation { location.id @@ -48,6 +160,12 @@ public struct FocusState: DynamicProperty where Value: Hashable { var location: AnyLocation? var resetValue: Value + /// The current state value, taking into account whatever bindings might be + /// in effect due to the current location of focus. + /// + /// When focus is not in any view that is bound to this state, the wrapped + /// value will be `nil` (for optional-typed state) or `false` (for `Bool`- + /// typed state). public var wrappedValue: Value { get { getValue(forReading: true) @@ -60,34 +178,72 @@ public struct FocusState: DynamicProperty where Value: Hashable { } } + /// A projection of the focus state value that returns a binding. + /// + /// When focus is outside any view that is bound to this state, the wrapped + /// value is `nil` for optional-typed state or `false` for Boolean state. + /// + /// In the following example of a simple navigation sidebar, when the user + /// presses the Filter Sidebar Contents button, focus moves to the sidebar's + /// filter text field. Conversely, if the user moves focus to the sidebar's + /// filter manually, then the value of `isFiltering` automatically + /// becomes `true`, and the sidebar view updates. + /// + /// struct Sidebar: View { + /// @State private var filterText = "" + /// @FocusState private var isFiltering: Bool + /// + /// var body: some View { + /// VStack { + /// Button("Filter Sidebar Contents") { + /// isFiltering = true + /// } + /// + /// TextField("Filter", text: $filterText) + /// .focused($isFiltering) + /// } + /// } + /// } public var projectedValue: FocusState.Binding { let value = getValue(forReading: false) - let binding: OpenSwiftUI.Binding + let binding: OpenSwiftUICore.Binding if let location { - binding = OpenSwiftUI.Binding(value: value, location: location) + binding = .init(value: value, location: location) } else { Log.runtimeIssues("Accessing FocusState's value outside of the body of a View. This will result in a constant Binding of the initial value and will not update.") binding = .constant(value) } return Binding(binding: binding) } - - public static func _makeProperty(in buffer: inout _DynamicPropertyBuffer, container: _GraphValue, fieldOffset: Int, inputs: inout _GraphInputs) { - // TODO + + public static func _makeProperty( + in buffer: inout _DynamicPropertyBuffer, + container: _GraphValue, + fieldOffset: Int, + inputs: inout _GraphInputs + ) { + let box = Box( + store: .init(inputs.focusStore), + focusedItem: .init(inputs.focusedItem), + location: nil + ) + buffer.append(box, fieldOffset: fieldOffset) } - + + /// Creates a focus state that binds to a Boolean. public init() where Value == Bool { value = false location = nil resetValue = false } - + + /// Creates a focus state that binds to an optional type. public init() where Value == T?, T: Hashable { value = nil location = nil resetValue = nil } - + private func getValue(forReading: Bool) -> Value { guard let location else { return value @@ -101,4 +257,411 @@ public struct FocusState: DynamicProperty where Value: Hashable { return location.get() } } + + struct Box: DynamicPropertyBox { + @OptionalAttribute var store: FocusStore? + @OptionalAttribute var focusedItem: FocusItem?? + var location: FocusStoreLocation? + + mutating func reset() { + location = nil + } + + mutating func update(property: inout FocusState, phase: _GraphInputs.Phase) -> Bool { + let oldLocation = location + let newLocation: FocusStoreLocation + if let oldLocation { + newLocation = oldLocation + } else { + newLocation = property.location as? FocusStoreLocation + ?? FocusStoreLocation(host: .currentHost, resetValue: property.resetValue) + location = newLocation + } + newLocation.store = store ?? FocusStore() + newLocation.focusSeed = (focusedItem ?? nil)?.seed ?? .empty + let (value, changed) = newLocation.update() + property.value = value + property.location = newLocation + if $store?.changedValue().changed ?? true { + #if os(macOS) + onNextMainRunLoop { [weak newLocation] in + newLocation?.performDeferredUpdate() + } + #else + newLocation.performDeferredUpdate() + #endif + } + return (changed && newLocation.wasRead) || oldLocation == nil + } + } +} + +@available(*, unavailable) +extension FocusState: Sendable {} + +@available(*, unavailable) +extension FocusState.Binding: Sendable {} + +// MARK: - View + Focus State + +@available(OpenSwiftUI_v3_0, *) +extension View { + /// Modifies this view by binding its focus state to the given state value. + /// + /// Use this modifier to cause the view to receive focus whenever + /// the `binding` equals the `value`. Typically, you create an enumeration + /// of fields that may receive focus, bind an instance of this enumeration, + /// and assign its cases to focusable views. + /// + /// The following example uses the cases of a `LoginForm` enumeration to + /// bind the focus state of two ``TextField`` views. A sign-in button + /// validates the fields and sets the bound `focusedField` value to + /// any field that requires the user to correct a problem. + /// + /// struct LoginForm: View { + /// enum Field: Hashable { + /// case usernameField + /// case passwordField + /// } + /// + /// @State private var username = "" + /// @State private var password = "" + /// @FocusState private var focusedField: Field? + /// + /// var body: some View { + /// Form { + /// TextField("Username", text: $username) + /// .focused($focusedField, equals: .usernameField) + /// + /// SecureField("Password", text: $password) + /// .focused($focusedField, equals: .passwordField) + /// + /// Button("Sign In") { + /// if username.isEmpty { + /// focusedField = .usernameField + /// } else if password.isEmpty { + /// focusedField = .passwordField + /// } else { + /// handleLogin(username, password) + /// } + /// } + /// } + /// } + /// } + /// + /// To control focus using a Boolean, use the ``View/focused(_:)`` method + /// instead. + /// + /// - Parameters: + /// - binding: The state binding to register. When focus moves to the + /// modified view, the binding sets the bound value to the corresponding + /// match value. If a caller sets the state value programmatically to the + /// matching value, then focus moves to the modified view. When focus + /// leaves the modified view, the binding sets the bound value to + /// `nil`. If a caller sets the value to `nil`, OpenSwiftUI automatically + /// dismisses focus. + /// - value: The value to match against when determining whether the + /// binding should change. + /// - Returns: The modified view. + nonisolated public func focused( + _ binding: FocusState.Binding, + equals value: Value + ) -> some View where Value: Hashable { + modifier(FocusStateBindingModifier(binding: binding, value: value)) + } + + /// Modifies this view by binding its focus state to the given Boolean state + /// value. + /// + /// Use this modifier to cause the view to receive focus whenever + /// the `condition` value is `true`. You can use this modifier to + /// observe the focus state of a single view, or programmatically set and + /// remove focus from the view. + /// + /// In the following example, a single ``TextField`` accepts a user's + /// desired `username`. The text field binds its focus state to the + /// Boolean value `usernameFieldIsFocused`. A "Submit" button's action + /// verifies whether the name is available. If the name is unavailable, the + /// button sets `usernameFieldIsFocused` to `true`, which causes focus to + /// return to the text field, so the user can enter a different name. + /// + /// @State private var username: String = "" + /// @FocusState private var usernameFieldIsFocused: Bool + /// @State private var showUsernameTaken = false + /// + /// var body: some View { + /// VStack { + /// TextField("Choose a username.", text: $username) + /// .focused($usernameFieldIsFocused) + /// if showUsernameTaken { + /// Text("That username is taken. Please choose another.") + /// } + /// Button("Submit") { + /// showUsernameTaken = false + /// if !isUserNameAvailable(username: username) { + /// usernameFieldIsFocused = true + /// showUsernameTaken = true + /// } + /// } + /// } + /// } + /// + /// To control focus by matching a value, use the + /// ``View/focused(_:equals:)`` method instead. + /// + /// - Parameter condition: The focus state to bind. When focus moves + /// to the view, the binding sets the bound value to `true`. If a caller + /// sets the value to `true` programmatically, then focus moves to the + /// modified view. When focus leaves the modified view, the binding + /// sets the value to `false`. If a caller sets the value to `false`, + /// OpenSwiftUI automatically dismisses focus. + /// + /// - Returns: The modified view. + nonisolated public func focused(_ condition: FocusState.Binding) -> some View { + focused(condition, equals: true) + } +} + +// MARK: - FocusStateBindingResponder [WIP] + +class FocusStateBindingResponder: DefaultLayoutViewResponder, BaseFocusResponder { + weak var focusBridge: FocusBridge? + var focusScopes: [Namespace.ID] = [] + + #if os(iOS) || os(visionOS) + var transform = ViewTransform() + var size: CGSize = .zero + var isEnabled: Bool = true + // TODO: UIKitContainerFocusResponderItem and AnyUIKitHostedFocusItemResponder. + #else + var isEnabled: Bool { + #if os(macOS) + // Deleted in the target image. + _openSwiftUIUnreachableCode() + #else + _openSwiftUIUnimplementedFailure() + #endif + } + #endif + + var platformItem: PlatformFocusItem? { + #if os(macOS) + // Deleted in the target image. + _openSwiftUIUnreachableCode() + #else + // TODO: Return hostedItem on UIKit platforms. + _openSwiftUIUnimplementedFailure() + #endif + } + + var viewItem: FocusItem.ViewItem? { + #if os(macOS) + nil + #elseif os(iOS) || os(visionOS) + // Deleted in the target image. + _openSwiftUIUnreachableCode() + #else + _openSwiftUIUnimplementedFailure() + #endif + } + + var frame: CGRect? { + #if os(macOS) + nil + #elseif os(iOS) || os(visionOS) + // Deleted in the target image. + _openSwiftUIUnreachableCode() + #else + _openSwiftUIUnimplementedFailure() + #endif + } + + #if os(macOS) + var effectiveLayoutDirection: LayoutDirection? { nil } + + var firstKeyViewInSubtree: PlatformView? { + get { nil } + set { _openSwiftUIEmptyStub() } + } + + var lastKeyViewInSubtree: PlatformView? { + get { nil } + set { _openSwiftUIEmptyStub() } + } + #endif + + #if os(iOS) || os(visionOS) + var focusGroupID: FocusGroupIdentifier? { + // Deleted in the target image. + _openSwiftUIUnreachableCode() + } + #endif +} + +// MARK: - FocusStateBindingModifier [WIP] + +private struct FocusStateBindingModifier: MultiViewModifier, PrimitiveViewModifier where Value: Hashable { + var binding: FocusState.Binding + var value: Value + + static func _makeView( + modifier: _GraphValue, + inputs: _ViewInputs, + body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs + ) -> _ViewOutputs { + var outputs = body(_Graph(), inputs) + guard inputs.preferences.requiresViewResponders else { + return outputs + } + let responder = FocusStateBindingResponder(inputs: inputs) + let filter = Attribute(FocusStateBindingResponderFilter( + inputs: inputs, + outputs: outputs, + responder: responder + )) + let list = Attribute(ListItemFilter( + modifier: modifier.value, + responder: responder, + focusItem: .init(inputs.focusedItem), + focusBridge: inputs.base.focusBridge, + focusScopes: inputs.base.focusScopes, + isFocused: false + )) + #if os(iOS) || os(visionOS) + // TODO: Attach UIKitHostedFocusItemLifecycle with removable and transactional flags. + _openSwiftUIUnimplementedWarning("UIKitHostedFocusItemLifecycle") + #endif + outputs.preferences.viewResponders = filter + outputs.preferences.makePreferenceTransformer( + inputs: inputs.preferences, + key: FocusStoreList.Key.self, + transform: Attribute(ListTransform(list: list)) + ) + return outputs + } + + struct ListTransform: Rule { + @Attribute var list: FocusStoreList + + var value: (inout FocusStoreList) -> Void { + let list = list + return { $0.append(contentsOf: list) } + } + } + + struct ListItemFilter: StatefulRule { + @Attribute var modifier: FocusStateBindingModifier + var responder: FocusStateBindingResponder + @OptionalAttribute var focusItem: FocusItem?? + @Attribute var focusBridge: FocusBridge? + @Attribute var focusScopes: [Namespace.ID] + var isFocused: Bool + + typealias Value = FocusStoreList + + mutating func updateValue() { + let (_, modifierChanged) = $modifier.changedValue() + let (scopes, scopesChanged) = $focusScopes.changedValue() + let newIsFocused = (focusItem ?? nil)?.responder?.isDescendant(of: responder) ?? false + let focusChanged = newIsFocused != isFocused + isFocused = newIsFocused + guard focusChanged || modifierChanged || scopesChanged || !hasValue else { + return + } + let bindingUpdateAction = FocusStateBindingUpdateAction( + binding: modifier.binding, + value: modifier.value + ) + let storeUpdateAction = FocusStoreUpdateAction( + value: modifier.value, + responder: responder, + bridge: focusBridge, + focusScopes: scopes + ) + value = FocusStoreList(items: [.init( + version: .init(forUpdate: ()), + propertyID: modifier.binding.propertyID, + bindingUpdateAction: bindingUpdateAction, + storeUpdateAction: storeUpdateAction, + responder: responder, + bridge: nil, + isFocused: isFocused + )]) + } + } +} + +// MARK: - EnvironmentValues + Focus Bridge + +private struct FocusBridgeKey: EnvironmentKey { + static var defaultValue: WeakBox { .init() } +} + +extension EnvironmentValues { + var focusBridge: FocusBridge? { + get { self[FocusBridgeKey.self].base } + set { self[FocusBridgeKey.self] = WeakBox(newValue) } + } +} + +extension CachedEnvironment.ID { + fileprivate static let focusBridge: CachedEnvironment.ID = .init() +} + +extension _GraphInputs { + var focusBridge: Attribute { + mapEnvironment(id: .focusBridge) { $0.focusBridge } + } +} + +// MARK: - FocusStateBindingResponderFilter + +private struct FocusStateBindingResponderFilter: StatefulRule { + @Attribute var children: [ViewResponder] + @Attribute var focusBridge: FocusBridge? + @Attribute var focusScopes: [Namespace.ID] + let responder: FocusStateBindingResponder + #if os(iOS) || os(visionOS) + @Attribute var transform: ViewTransform + @Attribute var position: CGPoint + @Attribute var size: ViewSize + @Attribute var isEnabled: Bool + #endif + + init(inputs: _ViewInputs, outputs: _ViewOutputs, responder: FocusStateBindingResponder) { + _children = outputs.viewResponders() + _focusBridge = inputs.base.focusBridge + _focusScopes = inputs.base.focusScopes + self.responder = responder + #if os(iOS) || os(visionOS) + _transform = inputs.transform + _position = inputs.animatedPosition() + _size = inputs.animatedSize() + _isEnabled = inputs.base.isEnabled + #endif + } + + typealias Value = [ViewResponder] + + mutating func updateValue() { + responder.updateChildren($children.changedValue()) + responder.focusBridge = focusBridge + responder.focusScopes = focusScopes + #if os(iOS) || os(visionOS) + let (position, positionChanged) = $position.changedValue() + let (transform, transformChanged) = $transform.changedValue() + let (size, sizeChanged) = $size.changedValue() + let (isEnabled, isEnabledChanged) = $isEnabled.changedValue() + if positionChanged || transformChanged || sizeChanged || isEnabledChanged || !hasValue { + var transform = transform + transform.appendPosition(position) + responder.transform = transform + responder.size = size.value + responder.isEnabled = isEnabled + } + #endif + if !hasValue { + value = [responder] + } + } } From 4b4852611e0c4d39b18a439abea2134afecbd69e Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 5 Oct 2026 20:58:49 +0800 Subject: [PATCH 19/22] feat(focus): implement UIKit hosted focus items --- .../UIKitContainerFocusResponderItemBase.h | 23 + .../UIKitContainerFocusResponderItemBase.m | 15 + .../UIKitFocusableViewResponderItemBase.h | 24 + .../UIKitFocusableViewResponderItemBase.m | 24 + .../COpenSwiftUI/Shims/UIKit/UIKit_Private.h | 10 + .../COpenSwiftUI/Shims/UIKit/UIKit_Private.m | 17 + .../OpenSwiftUI/Event/Focus/FocusState.swift | 20 +- .../UIKitHostContainerFocusItemInput.swift | 481 ++++++++++++++++++ .../View/List/PlatformListCell.swift | 14 + 9 files changed, 624 insertions(+), 4 deletions(-) create mode 100644 Sources/COpenSwiftUI/Overlay/UIKit/UIKitContainerFocusResponderItemBase.h create mode 100644 Sources/COpenSwiftUI/Overlay/UIKit/UIKitContainerFocusResponderItemBase.m create mode 100644 Sources/COpenSwiftUI/Overlay/UIKit/UIKitFocusableViewResponderItemBase.h create mode 100644 Sources/COpenSwiftUI/Overlay/UIKit/UIKitFocusableViewResponderItemBase.m create mode 100644 Sources/OpenSwiftUI/View/List/PlatformListCell.swift diff --git a/Sources/COpenSwiftUI/Overlay/UIKit/UIKitContainerFocusResponderItemBase.h b/Sources/COpenSwiftUI/Overlay/UIKit/UIKitContainerFocusResponderItemBase.h new file mode 100644 index 000000000..7e9c66420 --- /dev/null +++ b/Sources/COpenSwiftUI/Overlay/UIKit/UIKitContainerFocusResponderItemBase.h @@ -0,0 +1,23 @@ +// +// UIKitContainerFocusResponderItemBase.h +// COpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +#ifndef UIKitContainerFocusResponderItemBase_h +#define UIKitContainerFocusResponderItemBase_h + +#include "OpenSwiftUIBase.h" + +#if OPENSWIFTUI_TARGET_OS_IOS || OPENSWIFTUI_TARGET_OS_VISION + +#import + +OPENSWIFTUI_SWIFT_NAME(UIKitContainerFocusResponderItemBase) +@interface OpenSwiftUIUIKitContainerFocusResponderItemBase : NSObject +@end + +#endif + +#endif /* UIKitContainerFocusResponderItemBase_h */ diff --git a/Sources/COpenSwiftUI/Overlay/UIKit/UIKitContainerFocusResponderItemBase.m b/Sources/COpenSwiftUI/Overlay/UIKit/UIKitContainerFocusResponderItemBase.m new file mode 100644 index 000000000..8ba8ab942 --- /dev/null +++ b/Sources/COpenSwiftUI/Overlay/UIKit/UIKitContainerFocusResponderItemBase.m @@ -0,0 +1,15 @@ +// +// UIKitContainerFocusResponderItemBase.m +// COpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +#import "UIKitContainerFocusResponderItemBase.h" + +#if OPENSWIFTUI_TARGET_OS_IOS || OPENSWIFTUI_TARGET_OS_VISION + +@implementation OpenSwiftUIUIKitContainerFocusResponderItemBase +@end + +#endif diff --git a/Sources/COpenSwiftUI/Overlay/UIKit/UIKitFocusableViewResponderItemBase.h b/Sources/COpenSwiftUI/Overlay/UIKit/UIKitFocusableViewResponderItemBase.h new file mode 100644 index 000000000..dd73aa24d --- /dev/null +++ b/Sources/COpenSwiftUI/Overlay/UIKit/UIKitFocusableViewResponderItemBase.h @@ -0,0 +1,24 @@ +// +// UIKitFocusableViewResponderItemBase.h +// COpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +#ifndef UIKitFocusableViewResponderItemBase_h +#define UIKitFocusableViewResponderItemBase_h + +#include "OpenSwiftUIBase.h" + +#if OPENSWIFTUI_TARGET_OS_IOS || OPENSWIFTUI_TARGET_OS_VISION + +#import + +OPENSWIFTUI_SWIFT_NAME(UIKitFocusableViewResponderItemBase) +@interface OpenSwiftUIUIKitFocusableViewResponderItemBase : UIResponder +@property (nonatomic, readonly, nullable) NSString *openswiftui_focusGroupIdentifier; +@end + +#endif + +#endif /* UIKitFocusableViewResponderItemBase_h */ diff --git a/Sources/COpenSwiftUI/Overlay/UIKit/UIKitFocusableViewResponderItemBase.m b/Sources/COpenSwiftUI/Overlay/UIKit/UIKitFocusableViewResponderItemBase.m new file mode 100644 index 000000000..a3f35e9af --- /dev/null +++ b/Sources/COpenSwiftUI/Overlay/UIKit/UIKitFocusableViewResponderItemBase.m @@ -0,0 +1,24 @@ +// +// UIKitFocusableViewResponderItemBase.m +// COpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +#import "UIKitFocusableViewResponderItemBase.h" + +#if OPENSWIFTUI_TARGET_OS_IOS || OPENSWIFTUI_TARGET_OS_VISION + +@implementation OpenSwiftUIUIKitFocusableViewResponderItemBase + +- (NSString *)openswiftui_focusGroupIdentifier { + return nil; +} + +- (NSString *)focusGroupIdentifier { + return self.openswiftui_focusGroupIdentifier; +} + +@end + +#endif diff --git a/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.h b/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.h index f2900174d..da6085cd0 100644 --- a/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.h +++ b/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.h @@ -26,6 +26,15 @@ OPENSWIFTUI_ASSUME_NONNULL_BEGIN - (NSComparisonResult)accessibilityCompareGeometry:(NSObject *)element; @end +@interface NSObject (OpenSwiftUIFocus) +- (void)_requestFocusEffectUpdateToEnvironment:(id)environment OPENSWIFTUI_SWIFT_NAME(_requestFocusEffectUpdate(to:)); +@end + +@interface UIFocusSystem (OpenSwiftUI_SPI) +- (void)_focusEnvironmentWillDisappear_openswiftui_safe_wrapper:(id)environment OPENSWIFTUI_SWIFT_NAME(_focusEnvironmentWillDisappear(_:)); +- (void)_focusEnvironmentDidAppear_openswiftui_safe_wrapper:(id)environment OPENSWIFTUI_SWIFT_NAME(_focusEnvironmentDidAppear(_:)); +@end + @interface UIApplication (OpenSwiftUI_SPI) - (nullable NSString *)_extendLaunchTest; - (BOOL)_supportsPrintCommand; @@ -98,6 +107,7 @@ OPENSWIFTUI_ASSUME_NONNULL_BEGIN @end @interface UIWindowScene (OpenSwiftUI_SPI) +@property (nonatomic, readonly, nullable) NSObject *_focusSystemSceneComponent_openswiftui_safe_wrapper OPENSWIFTUI_SWIFT_NAME(_focusSystemSceneComponent); @property (nonatomic, readonly) UIUserInterfaceStyle _systemUserInterfaceStyle_openswiftui_safe_wrapper OPENSWIFTUI_SWIFT_NAME(_systemUserInterfaceStyle); #if OPENSWIFTUI_LINK_BACKLIGHTSERVICES @property (nonatomic, readonly, nullable) BLSBacklightFBSSceneEnvironment *_backlightSceneEnvironment_openswiftui_safe_wrapper OPENSWIFTUI_SWIFT_NAME(_backlightSceneEnvironment); diff --git a/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.m b/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.m index 0439c41f9..42760b557 100644 --- a/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.m +++ b/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.m @@ -11,6 +11,18 @@ #import +@implementation UIFocusSystem (OpenSwiftUI_SPI) +- (void)_focusEnvironmentWillDisappear_openswiftui_safe_wrapper:(id)environment { + OPENSWIFTUI_SAFE_WRAPPER_IMP(void, @"_focusEnvironmentWillDisappear:", , id); + func(self, selector, environment); +} + +- (void)_focusEnvironmentDidAppear_openswiftui_safe_wrapper:(id)environment { + OPENSWIFTUI_SAFE_WRAPPER_IMP(void, @"_focusEnvironmentDidAppear:", , id); + func(self, selector, environment); +} +@end + @implementation UIApplication (OpenSwiftUI_SPI) - (void)_performBlockAfterCATransactionCommits_openswiftui_safe_wrapper:(void (^)(void))block { OPENSWIFTUI_SAFE_WRAPPER_IMP(void, @"_performBlockAfterCATransactionCommits:", block(),void (^)(void)); @@ -108,6 +120,11 @@ - (UIWindowScene *) _windowHostingScene_openswiftui_safe_wrapper { @end @implementation UIWindowScene (OpenSwiftUI_SPI) +- (NSObject *)_focusSystemSceneComponent_openswiftui_safe_wrapper { + OPENSWIFTUI_SAFE_WRAPPER_IMP(NSObject *, @"_focusSystemSceneComponent", nil); + return func(self, selector); +} + - (UIUserInterfaceStyle) _systemUserInterfaceStyle_openswiftui_safe_wrapper { OPENSWIFTUI_SAFE_WRAPPER_IMP(UIUserInterfaceStyle, @"_systemUserInterfaceStyle", UIUserInterfaceStyleUnspecified); return func(self, selector); diff --git a/Sources/OpenSwiftUI/Event/Focus/FocusState.swift b/Sources/OpenSwiftUI/Event/Focus/FocusState.swift index 337490ce2..e76967f86 100644 --- a/Sources/OpenSwiftUI/Event/Focus/FocusState.swift +++ b/Sources/OpenSwiftUI/Event/Focus/FocusState.swift @@ -432,7 +432,7 @@ class FocusStateBindingResponder: DefaultLayoutViewResponder, BaseFocusResponder var transform = ViewTransform() var size: CGSize = .zero var isEnabled: Bool = true - // TODO: UIKitContainerFocusResponderItem and AnyUIKitHostedFocusItemResponder. + var _uikitFocusItem: UIKitContainerFocusResponderItem? #else var isEnabled: Bool { #if os(macOS) @@ -448,8 +448,9 @@ class FocusStateBindingResponder: DefaultLayoutViewResponder, BaseFocusResponder #if os(macOS) // Deleted in the target image. _openSwiftUIUnreachableCode() + #elseif os(iOS) || os(visionOS) + hostedItem #else - // TODO: Return hostedItem on UIKit platforms. _openSwiftUIUnimplementedFailure() #endif } @@ -498,6 +499,17 @@ class FocusStateBindingResponder: DefaultLayoutViewResponder, BaseFocusResponder #endif } +#if os(iOS) || os(visionOS) +extension FocusStateBindingResponder: AnyUIKitHostedFocusItemResponder { + var hostedItem: (any AnyUIKitHostedFocusItem)? { + if _uikitFocusItem == nil { + _uikitFocusItem = UIKitContainerFocusResponderItem(self) + } + return _uikitFocusItem + } +} +#endif + // MARK: - FocusStateBindingModifier [WIP] private struct FocusStateBindingModifier: MultiViewModifier, PrimitiveViewModifier where Value: Hashable { @@ -528,8 +540,8 @@ private struct FocusStateBindingModifier: MultiViewModifier, PrimitiveVie isFocused: false )) #if os(iOS) || os(visionOS) - // TODO: Attach UIKitHostedFocusItemLifecycle with removable and transactional flags. - _openSwiftUIUnimplementedWarning("UIKitHostedFocusItemLifecycle") + let lifecycle = Attribute(UIKitHostedFocusItemLifecycle(inputs: inputs, responder: filter)) + lifecycle.flags = [.removable, .transactional] #endif outputs.preferences.viewResponders = filter outputs.preferences.makePreferenceTransformer( diff --git a/Sources/OpenSwiftUI/Event/Focus/UIKitHostContainerFocusItemInput.swift b/Sources/OpenSwiftUI/Event/Focus/UIKitHostContainerFocusItemInput.swift index 5d5fc0381..3b20a4f2f 100644 --- a/Sources/OpenSwiftUI/Event/Focus/UIKitHostContainerFocusItemInput.swift +++ b/Sources/OpenSwiftUI/Event/Focus/UIKitHostContainerFocusItemInput.swift @@ -7,11 +7,229 @@ // ID: 10718FCC504A33B6994038B6E6E29C50 (SwiftUI?) #if os(iOS) || os(visionOS) +import COpenSwiftUI import OpenAttributeGraphShims @_spi(ForOpenSwiftUIOnly) import OpenSwiftUICore import UIKit +// MARK: - AnyUIKitHostedFocusItemResponder + +protocol AnyUIKitHostedFocusItemResponder: ViewResponder, BaseFocusResponder { + var hostedItem: (any AnyUIKitHostedFocusItem)? { get } +} + +// MARK: - AnyUIKitHostedFocusItem + +protocol AnyUIKitHostedFocusItem: UIFocusItem { + var host: UIView? { get set } + var frame: CGRect { get set } + var responder: (ViewResponder & BaseFocusResponder)? { get } + func ensureHost() +} + +extension AnyUIKitHostedFocusItem { + func ensureHost() { + guard host == nil else { return } + let root = responder?.firstAncestor(ofType: UIViewResponder.self)?.hostView + ?? responder?.host?.as(UIView.self) + guard let root else { return } + var views = [root] + while !views.isEmpty { + let view = views.removeFirst() + if view is any UIKitContainerFocusItem { + addToHostIfNeeded(view) + return + } + views.append(contentsOf: view.subviews) + } + } + + fileprivate func addToHostIfNeeded(_ host: UIView) { + guard self.host == nil else { return } + self.host = host + } + + fileprivate func move(toParent parent: UIView?) { + guard host !== parent else { return } + if let host { + UIFocusSystem.focusSystem(for: host)?._focusEnvironmentWillDisappear(self) + } + host = parent + if let host { + UIFocusSystem.focusSystem(for: host)?._focusEnvironmentDidAppear(self) + } + } + + fileprivate func invalidateFocusIfNeeded() { + guard let host, isFocused, !canBecomeFocused else { return } + host.setNeedsFocusUpdate() + } +} + +// MARK: - UIKitHostedFocusItem + +protocol UIKitHostedFocusItem: AnyUIKitHostedFocusItem { + var id: ViewIdentity { get } +} + +// MARK: - UIKitHostedContainerFocusItem + +protocol UIKitHostedContainerFocusItem: AnyUIKitHostedFocusItem, UIKitContainerFocusItem { + var providesDefaultFocusItems: Bool { get } + func defaultFocusItems() -> [any UIFocusItem] +} + +extension UIKitHostedContainerFocusItem { + func defaultFocusItems() -> [any UIFocusItem] { + guard let root = rootResponder(), let host else { return [] } + return FocusBridge.defaultFocusItems(responderNode: root.responder, host: host) + } +} + +// MARK: - UIKitContainerFocusItem + +protocol UIKitContainerFocusItem: UIFocusItem, UIFocusItemContainer { + var host: UIView? { get } + func rootResponder() -> (responder: ResponderNode, isVisited: Bool)? + func childFocusItems(in rect: CGRect) -> [any UIFocusItem] + func defaultFocusItemsContainer() -> (any UIKitHostedContainerFocusItem)? +} + +extension UIKitContainerFocusItem { + func childFocusItems(in rect: CGRect) -> [any UIFocusItem] { + guard let root = rootResponder(), let host else { return [] } + return FocusBridge.focusItems( + responderNode: root.responder, + rect: rect, + host: host, + skipRoot: root.isVisited + ) + } + + func defaultFocusItemsContainer() -> (any UIKitHostedContainerFocusItem)? { + guard let root = rootResponder(), let host else { return nil } + return FocusBridge.defaultFocusItemsContainer(responderNode: root.responder, host: host) + } +} + +extension UIKitContainerFocusItem where Self: AnyUIKitHostedFocusItem { + func rootResponder() -> (responder: ResponderNode, isVisited: Bool)? { + responder.map { ($0, true) } + } +} + +extension UIKitContainerFocusItem where Self: UIView { + func rootResponder() -> (responder: ResponderNode, isVisited: Bool)? { + guard let renderer = nearestRenderer() else { return nil } + if renderer === self { + return renderer.responderNode.map { ($0, false) } + } else { + return nearestResponder(in: renderer).map { ($0, true) } + } + } +} + +extension _UIHostingView: UIKitContainerFocusItem { + var host: UIView? { self } +} + +// MARK: - UIKitHostContainer [WIP] + +protocol UIKitHostContainer { + var visibleHostCells: [any PlatformListCell & UIFocusItem] { get } + var hasSelection: Bool { get } +} + +// MARK: - UIKitContainerFocusResponderItem + +class UIKitContainerFocusResponderItem: UIKitContainerFocusResponderItemBase, UIKitHostedContainerFocusItem where A: ViewResponder, A: BaseFocusResponder { + weak var base: A? + weak var host: UIView? + var frame: CGRect = .zero + var isEnabled: Bool = true + private var cachedCoordinateSpace: (any UICoordinateSpace)? + + init(_ base: A) { + self.base = base + super.init() + } + + var providesDefaultFocusItems: Bool { false } + + var responder: (ViewResponder & BaseFocusResponder)? { base } + + var canBecomeFocused: Bool { false } + + var preferredFocusEnvironments: [any UIFocusEnvironment] { [] } + + var parentFocusEnvironment: (any UIFocusEnvironment)? { host } + + var focusItemContainer: (any UIFocusItemContainer)? { self } + + func setNeedsFocusUpdate() { + UIFocusSystem.focusSystem(for: self)?.requestFocusUpdate(to: self) + } + + func updateFocusIfNeeded() { + UIFocusSystem.focusSystem(for: self)?.updateFocusIfNeeded() + } + + func shouldUpdateFocus(in context: UIFocusUpdateContext) -> Bool { true } + + func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { + _openSwiftUIEmptyStub() + } + + var coordinateSpace: any UICoordinateSpace { + if cachedCoordinateSpace == nil { + cachedCoordinateSpace = WrapperCoordinateSpace(host: host, frame: frame) + } + return cachedCoordinateSpace! + } + + func focusItems(in rect: CGRect) -> [any UIFocusItem] { + childFocusItems(in: rect) + } + + @objc + class func _supportsInvalidatingFocusCache() -> Bool { true } + + // The public UIKit SDK does not expose the SPI parameter and result types. + @objc(_focusGuideBehaviorForFocusMovement:) + func _focusGuideBehavior(forFocusMovement movement: AnyObject?) -> UInt { 0 } + + private class WrapperCoordinateSpace: NSObject, UICoordinateSpace { + weak var host: UIView? + let frame: CGRect + + init(host: UIView?, frame: CGRect) { + self.host = host + self.frame = frame + super.init() + } + + func convert(_ point: CGPoint, from coordinateSpace: any UICoordinateSpace) -> CGPoint { + // The point overloads use the opposite direction in 6.5.4. + host?.coordinateSpace.convert(point, to: coordinateSpace) ?? .zero + } + + func convert(_ point: CGPoint, to coordinateSpace: any UICoordinateSpace) -> CGPoint { + host?.coordinateSpace.convert(point, from: coordinateSpace) ?? .zero + } + + func convert(_ rect: CGRect, to coordinateSpace: any UICoordinateSpace) -> CGRect { + host?.coordinateSpace.convert(rect, to: coordinateSpace) ?? .zero + } + + func convert(_ rect: CGRect, from coordinateSpace: any UICoordinateSpace) -> CGRect { + host?.coordinateSpace.convert(rect, from: coordinateSpace) ?? .zero + } + + var bounds: CGRect { frame } + } +} + // MARK: - UIKitHostContainerCoordinateSpace let UIKitHostContainerCoordinateSpace: CoordinateSpace.ID = .init() @@ -21,4 +239,267 @@ let UIKitHostContainerCoordinateSpace: CoordinateSpace.ID = .init() struct UIKitHostContainerFocusItemInput: ViewInput { static let defaultValue: OptionalAttribute> = .init() } + +// MARK: - UIKitHostedFocusItemLifecycle + +struct UIKitHostedFocusItemLifecycle: StatefulRule, ObservedAttribute, RemovableAttribute { + @Attribute var phase: _GraphInputs.Phase + @Attribute var transform: ViewTransform + @Attribute var position: CGPoint + @Attribute var size: ViewSize + @OptionalAttribute var host: WeakBox? + @OptionalAttribute var isFocusSystemEnabled: Bool? + @Attribute var responder: [ViewResponder] + weak var lastHost: UIView? + var lastResetSeed: UInt32 = 0 + var isFocusable: Bool? + var frame: CGRect? + var hostedItem: (any AnyUIKitHostedFocusItem)? + var queriedResponder: (any AnyUIKitHostedFocusItemResponder)? + private var enqueuedEvents: [Event] = [] + + typealias Value = Void + + init(inputs: _ViewInputs, responder: Attribute<[ViewResponder]>) { + _phase = inputs.viewPhase + _transform = inputs.transform + _position = inputs.animatedPosition() + _size = inputs.animatedSize() + _host = inputs[UIKitHostContainerFocusItemInput.self] + _isFocusSystemEnabled = .init(inputs.base.isFocusSystemEnabled) + _responder = responder + } + + mutating func updateValue() { + guard isFocusSystemEnabled == true else { return } + lastHost = host?.base + if lastResetSeed != phase.resetSeed { + lastResetSeed = phase.resetSeed + remove() + hostedItem = nil + frame = nil + isFocusable = nil + } + if let first = responder.first, first !== queriedResponder { + queriedResponder = first as? any AnyUIKitHostedFocusItemResponder + } + guard let item = queriedResponder?.hostedItem else { + flushQueue() + return + } + var transform = transform + transform.appendPosition(position) + var frame = CGRect(origin: .zero, size: size.value) + frame.convert(to: .id(UIKitHostContainerCoordinateSpace), transform: transform) + let isFocusable = item.canBecomeFocused + if hostedItem == nil { + hostedItem = item + self.frame = frame + self.isFocusable = isFocusable + insert() + } else { + if self.frame != frame { + self.frame = frame + invalidateFrame() + } + if self.isFocusable != isFocusable { + self.isFocusable = isFocusable + invalidateFocusIfNeeded() + } + } + flushQueue() + } + + static func willRemove(attribute: AnyAttribute) { + let body = UnsafeMutableRawPointer(mutating: attribute.info.body) + .assumingMemoryBound(to: Self.self) + body.pointee.remove() + } + + static func didReinsert(attribute: AnyAttribute) { + let body = UnsafeMutableRawPointer(mutating: attribute.info.body) + .assumingMemoryBound(to: Self.self) + if body.pointee.insert() { + attribute.invalidateValue() + } + } + + mutating func destroy() { + remove() + flushQueue() + } + + mutating func flushQueue() { + let events = enqueuedEvents + enqueuedEvents = [] + Update.enqueueAction { + for event in events { + event.action() + } + } + } + + private mutating func remove() { + guard let hostedItem else { return } + if enqueuedEvents.first?.type == .inserted { + enqueuedEvents = [] + } else { + enqueuedEvents = [Event(type: .removed) { + hostedItem.move(toParent: nil) + }] + } + } + + @discardableResult + private mutating func insert() -> Bool { + guard let lastHost, let hostedItem, let frame else { return false } + enqueuedEvents.append(Event(type: .inserted) { + hostedItem.frame = frame + hostedItem.move(toParent: lastHost) + }) + return true + } + + private mutating func invalidateFrame() { + guard let hostedItem, let frame else { return } + enqueuedEvents.append(Event(type: .updated) { + hostedItem.frame = frame + if hostedItem.isFocused, + let component = hostedItem.host?.window?.windowScene?._focusSystemSceneComponent, + component.responds(to: #selector(NSObject._requestFocusEffectUpdate(to:))) { + component._requestFocusEffectUpdate(to: hostedItem) + } + }) + } + + private mutating func invalidateFocusIfNeeded() { + guard let hostedItem else { return } + enqueuedEvents.append(Event(type: .updated) { + hostedItem.invalidateFocusIfNeeded() + }) + } + + private struct Event { + var type: EventType + var action: () -> Void + } + + private enum EventType: Hashable { + case updated + case inserted + case removed + } +} + +// MARK: - FocusBridge + UIKit containers + +extension FocusBridge { + fileprivate static func focusItems( + responderNode: ResponderNode, + rect: CGRect, + host: UIView, + skipRoot: Bool + ) -> [any UIFocusItem] { + var items: [any UIFocusItem] = [] + responderNode.visitBaseFocusResponders { responder in + if skipRoot, responder === responderNode { + return .next + } + if _SemanticFeature_v6.isEnabled, !responder.isEnabled { + return .skipToNextSibling + } + guard let item = responder.platformItem else { return .next } + let frame: CGRect + if let view = item as? UIView { + frame = view.convert(view.bounds, to: host as any UICoordinateSpace) + } else if let hostedItem = item as? any AnyUIKitHostedFocusItem { + hostedItem.addToHostIfNeeded(host) + frame = hostedItem.frame + } else { + return .skipToNextSibling + } + if !frame.intersection(rect).isEmpty { + items.append(item) + } + return .skipToNextSibling + } + return items + } + + fileprivate static func defaultFocusItems(responderNode: ResponderNode, host: UIView) -> [any UIFocusItem] { + var items: [any UIFocusItem] = [] + responderNode.visitBaseFocusResponders { responder in + if _SemanticFeature_v6.isEnabled, !responder.isEnabled { + return .skipToNextSibling + } + guard let item = responder.platformItem else { return .next } + if let container = item as? any UIKitHostedContainerFocusItem, + !container.providesDefaultFocusItems { + return .next + } + (item as? any AnyUIKitHostedFocusItem)?.addToHostIfNeeded(host) + items.append(item) + return .skipToNextSibling + } + return items + } + + fileprivate static func defaultFocusItemsContainer( + responderNode: ResponderNode, + host: UIView + ) -> (any UIKitHostedContainerFocusItem)? { + var result: (any UIKitHostedContainerFocusItem)? + responderNode.visitBaseFocusResponders { responder in + if _SemanticFeature_v6.isEnabled, !responder.isEnabled { + return .skipToNextSibling + } + guard let item = responder.platformItem as? any UIKitHostedContainerFocusItem else { + result = nil + return .cancel + } + guard item.providesDefaultFocusItems else { return .next } + guard result == nil else { + result = nil + return .cancel + } + item.addToHostIfNeeded(host) + result = item + return .skipToNextSibling + } + return result + } +} + +// MARK: - UIFocusEnvironment + responders + +extension UIFocusEnvironment { + fileprivate func nearestRenderer() -> (any ViewRendererHost)? { + var environment: (any UIFocusEnvironment)? = self + while let current = environment { + if let renderer = current as? any ViewRendererHost { + return renderer + } + environment = current.parentFocusEnvironment + } + return nil + } + + fileprivate func nearestResponder(in renderer: any ViewRendererHost) -> ResponderNode? { + _ = renderer.responderNode + if let item = self as? any AnyUIKitHostedFocusItem, + nearestRenderer() === renderer { + return item.responder + } + var environment: (any UIFocusEnvironment)? = self + while let current = environment { + if let host = current as? any AnyPlatformViewHost, + let responder = host.responder, + responder.host === renderer { + return responder + } + environment = current.parentFocusEnvironment + } + return nil + } +} #endif diff --git a/Sources/OpenSwiftUI/View/List/PlatformListCell.swift b/Sources/OpenSwiftUI/View/List/PlatformListCell.swift new file mode 100644 index 000000000..ac7f92d7a --- /dev/null +++ b/Sources/OpenSwiftUI/View/List/PlatformListCell.swift @@ -0,0 +1,14 @@ +// +// PlatformListCell.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: WIP + +// MARK: - PlatformListViewBase [WIP] + +protocol PlatformListViewBase: AnyObject {} + +// MARK: - PlatformListCell [WIP] + +protocol PlatformListCell: PlatformListViewBase {} From 25212f6294a471f4194f19e28406061a58914718 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 5 Oct 2026 22:24:48 +0800 Subject: [PATCH 20/22] fix(focus): restore UIKit focus logging --- .../UIKitHostContainerFocusItemInput.swift | 87 +++++++++++++++++-- Sources/OpenSwiftUICore/Log/Logging.swift | 9 ++ 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/Sources/OpenSwiftUI/Event/Focus/UIKitHostContainerFocusItemInput.swift b/Sources/OpenSwiftUI/Event/Focus/UIKitHostContainerFocusItemInput.swift index 3b20a4f2f..bbd0215dd 100644 --- a/Sources/OpenSwiftUI/Event/Focus/UIKitHostContainerFocusItemInput.swift +++ b/Sources/OpenSwiftUI/Event/Focus/UIKitHostContainerFocusItemInput.swift @@ -3,7 +3,7 @@ // OpenSwiftUI // // Audited for 6.5.4 -// Status: WIP +// Status: Complete // ID: 10718FCC504A33B6994038B6E6E29C50 (SwiftUI?) #if os(iOS) || os(visionOS) @@ -47,22 +47,26 @@ extension AnyUIKitHostedFocusItem { fileprivate func addToHostIfNeeded(_ host: UIView) { guard self.host == nil else { return } + Log.focus?.log("adding unmanaged: \(UIKitFocusItemDescription(self)) to: \(UIKitFocusItemDescription(host))") self.host = host } fileprivate func move(toParent parent: UIView?) { guard host !== parent else { return } if let host { + Log.focus?.log("unparenting: \(UIKitFocusItemDescription(self)) from \(UIKitFocusItemDescription(host))") UIFocusSystem.focusSystem(for: host)?._focusEnvironmentWillDisappear(self) } host = parent if let host { + Log.focus?.log("parenting: \(UIKitFocusItemDescription(self)) to: \(UIKitFocusItemDescription(host))") UIFocusSystem.focusSystem(for: host)?._focusEnvironmentDidAppear(self) } } fileprivate func invalidateFocusIfNeeded() { guard let host, isFocused, !canBecomeFocused else { return } + Log.focus?.log("invalidating focus in \(UIKitFocusItemDescription(host))") host.setNeedsFocusUpdate() } } @@ -134,7 +138,7 @@ extension _UIHostingView: UIKitContainerFocusItem { var host: UIView? { self } } -// MARK: - UIKitHostContainer [WIP] +// MARK: - UIKitHostContainer protocol UIKitHostContainer { var visibleHostCells: [any PlatformListCell & UIFocusItem] { get } @@ -195,7 +199,6 @@ class UIKitContainerFocusResponderItem: UIKitContainerFocusResponderItemBase, @objc class func _supportsInvalidatingFocusCache() -> Bool { true } - // The public UIKit SDK does not expose the SPI parameter and result types. @objc(_focusGuideBehaviorForFocusMovement:) func _focusGuideBehavior(forFocusMovement movement: AnyObject?) -> UInt { 0 } @@ -209,8 +212,8 @@ class UIKitContainerFocusResponderItem: UIKitContainerFocusResponderItemBase, super.init() } + // NOTE: The point overloads use the opposite direction func convert(_ point: CGPoint, from coordinateSpace: any UICoordinateSpace) -> CGPoint { - // The point overloads use the opposite direction in 6.5.4. host?.coordinateSpace.convert(point, to: coordinateSpace) ?? .zero } @@ -409,20 +412,24 @@ extension FocusBridge { return .skipToNextSibling } guard let item = responder.platformItem else { return .next } - let frame: CGRect + let frame: CGRect? if let view = item as? UIView { frame = view.convert(view.bounds, to: host as any UICoordinateSpace) } else if let hostedItem = item as? any AnyUIKitHostedFocusItem { hostedItem.addToHostIfNeeded(host) frame = hostedItem.frame } else { - return .skipToNextSibling + Log.focus?.error("unknown focus item: \(UIKitFocusItemDescription(item))") + frame = nil } - if !frame.intersection(rect).isEmpty { + if let frame, !frame.intersection(rect).isEmpty { items.append(item) + } else { + Log.focus?.log("skipped: \(UIKitFocusItemDescription(item)) with: \((frame ?? .zero).loggable) in: \(rect.loggable) for: \(UIKitFocusItemDescription(host))") } return .skipToNextSibling } + Log.focus?.log("focus items queried: \(items.count) in: \(rect.loggable) for: \(UIKitFocusItemDescription(host))") return items } @@ -502,4 +509,70 @@ extension UIFocusEnvironment { return nil } } + +// MARK: - UIKitFocusItemDescription + +struct UIKitFocusItemDescription: CustomStringConvertible where Item: UIFocusItem { + var description: String + + init(_ item: Item) { + let category = Category(item) + description = "<\(category.name): \(address(of: item))" + for attribute in category.attributes { + description += "; " + attribute + } + description += ">" + } + + enum Category { + case host(UIView & UIKitContainerFocusItem) + case container(any UIKitHostedContainerFocusItem) + case item(any AnyUIKitHostedFocusItem) + case unknown(Item) + + init(_ item: Item) { + if let host = item as? UIView & UIKitContainerFocusItem { + self = .host(host) + } else if let container = item as? any UIKitHostedContainerFocusItem { + self = .container(container) + } else if let item = item as? any AnyUIKitHostedFocusItem { + self = .item(item) + } else { + self = .unknown(item) + } + } + + var name: String { + switch self { + case .host: "Host" + case .container: "Container" + case .item: "Item" + case let .unknown(item): "Unknown<\(_typeName(type(of: item), qualified: false))>" + } + } + + var attributes: [String] { + switch self { + case .host: + [] + case .container: + ["responder: \(addressOfResponder ?? "??")"] + case let .item(item): + ["responder: \(addressOfResponder ?? "??")", "focused: \(item.isFocused)"] + case let .unknown(item): + ["focused: \(item.isFocused)"] + } + } + + private var addressOfResponder: String? { + let responder: ViewResponder? + switch self { + case let .container(item): responder = item.responder + case let .item(item): responder = item.responder + case .host, .unknown: return nil + } + return responder.map { "\(address(of: $0))" } + } + } +} #endif diff --git a/Sources/OpenSwiftUICore/Log/Logging.swift b/Sources/OpenSwiftUICore/Log/Logging.swift index d2c41ee9d..c16f49987 100644 --- a/Sources/OpenSwiftUICore/Log/Logging.swift +++ b/Sources/OpenSwiftUICore/Log/Logging.swift @@ -4,6 +4,7 @@ // // Audited for 6.0.87 // Status: Complete +// ID: EF4F47D847BB5F23A034A6658EC56E22 (SwiftUICore) import Foundation #if OPENSWIFTUI_LINK_TESTING @@ -234,6 +235,8 @@ package enum Log { package static var events: OSLog = OSLog(subsystem: subsystem, category: "Events") #endif + package static let focus: Logger? = EnableFocusLogging.isEnabled ? Logger(subsystem: subsystem, category: "Focus") : nil + package static let archiving: Logger = Logger(subsystem: subsystem, category: "Archiving") package static let archivedToggle: Logger = Logger(subsystem: subsystem, category: "ArchivedToggle") package static let archivedButton: Logger = Logger(subsystem: subsystem, category: "ArchivedButton") @@ -254,6 +257,12 @@ package enum Log { @available(*, unavailable) extension Log: Sendable {} +private struct EnableFocusLogging: UserDefaultKeyedFeature { + static var key: String { "org.OpenSwiftUIProject.OpenSwiftUI.EnableFocusLogging" } + + static var cachedValue: Bool? +} + @_transparent package func precondition(_ condition: @autoclosure () -> Bool, _ message: @autoclosure () -> String, file: StaticString = #fileID, line: UInt = #line) { guard condition() else { From bda9a81ecabb79a21bf60020d8b7908999faeb93 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 5 Oct 2026 22:26:01 +0800 Subject: [PATCH 21/22] feat(focus): implement UIViewResponder focus items --- .../Platform/UIKit/UIViewResponder.swift | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/Sources/OpenSwiftUI/Event/Platform/UIKit/UIViewResponder.swift b/Sources/OpenSwiftUI/Event/Platform/UIKit/UIViewResponder.swift index a73bfbe43..651a5c673 100644 --- a/Sources/OpenSwiftUI/Event/Platform/UIKit/UIViewResponder.swift +++ b/Sources/OpenSwiftUI/Event/Platform/UIKit/UIViewResponder.swift @@ -3,7 +3,7 @@ // OpenSwiftUI // // Audited for 6.5.4 -// Status: Complete (Blocked by FocusResponder) +// Status: Complete #if os(iOS) || os(visionOS) @_spi(ForOpenSwiftUIOnly) @@ -67,14 +67,40 @@ class UIViewResponder: PlatformViewResponderBase { } var wantsOpenSwiftUIFocusItem: Bool { - _openSwiftUIUnimplementedFailure() + guard let view = preferredFocusableView ?? representedView else { + return false + } + if _SemanticFeature_v5.isEnabled { + return view.firstFocusableDescendant != nil + } else { + return view.canBecomeFirstResponder + } } var focusItem: FocusItem? { - _openSwiftUIUnimplementedFailure() + guard let representedView else { return nil } + if UIFocusSystem.focusSystem(for: representedView) != nil { + let prefersFocusSystem = representedView.traitCollection.userInterfaceIdiom == .carPlay + let item = preferredFocusableView + ?? (_SemanticFeature_v5.isEnabled ? representedView.firstFocusableDescendant : nil) + ?? representedView + return FocusItem( + base: .platformItem(WeakBox(item)), + prefersFocusSystem: prefersFocusSystem, + responder: self + ) + } else if wantsOpenSwiftUIFocusItem { + return FocusItem( + base: .platformResponder(WeakBox(preferredFocusableView ?? representedView)), + prefersFocusSystem: false, + responder: self + ) + } else { + return nil + } } } -// TODO: FocusResponder conformance +extension UIViewResponder: FocusResponder {} #endif From 8e63e2f76310a19af3fbf85737181d87cd95e8d0 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 5 Oct 2026 23:08:27 +0800 Subject: [PATCH 22/22] feat(focus): implement AppKit responders and shared view providers --- .../Focus/PlatformFocusViewProvider.swift | 18 ++++++++ .../Platform/AppKit/NSViewResponder.swift | 42 ++++++++++++++++--- .../Responder/PlatformViewResponderBase.swift | 9 ++++ .../Platform/PlatformViewHost.swift | 9 ++++ 4 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 Sources/OpenSwiftUI/Event/Focus/PlatformFocusViewProvider.swift diff --git a/Sources/OpenSwiftUI/Event/Focus/PlatformFocusViewProvider.swift b/Sources/OpenSwiftUI/Event/Focus/PlatformFocusViewProvider.swift new file mode 100644 index 000000000..ca5ba02ba --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Focus/PlatformFocusViewProvider.swift @@ -0,0 +1,18 @@ +// +// PlatformFocusViewProvider.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +// MARK: - PlatformFocusViewProvider + +protocol PlatformFocusViewProvider: PlatformView { + var focusView: PlatformView { get } +} + +// MARK: - PlatformFocusResponderProvider + +protocol PlatformFocusResponderProvider: PlatformView { + var focusViewResponder: PlatformViewResponder? { get } +} diff --git a/Sources/OpenSwiftUI/Event/Platform/AppKit/NSViewResponder.swift b/Sources/OpenSwiftUI/Event/Platform/AppKit/NSViewResponder.swift index cec954fb5..8cb8c70a4 100644 --- a/Sources/OpenSwiftUI/Event/Platform/AppKit/NSViewResponder.swift +++ b/Sources/OpenSwiftUI/Event/Platform/AppKit/NSViewResponder.swift @@ -3,7 +3,7 @@ // OpenSwiftUI // // Audited for 6.5.4 -// Status: Complete (Blocked by FocusResponder) +// Status: Complete #if os(macOS) import AppKit @@ -78,17 +78,49 @@ class NSViewResponder: PlatformViewResponderBase { } var focusItem: FocusItem? { - _openSwiftUIUnimplementedFailure() + guard let view = preferredFocusableView + ?? (hostView as? any PlatformFocusViewProvider)?.focusView else { + return nil + } + return FocusItem( + base: .platformResponder(WeakBox(view)), + prefersFocusSystem: false, + responder: self + ) } var isInVisibleRect: Bool { - _openSwiftUIUnimplementedFailure() + let frame = self.frame ?? .zero + guard var visibleRect = helper.transform.containingScrollGeometry?.visibleRect else { + return true + } + visibleRect.convert(to: .id(hostingViewCoordinateSpace), transform: helper.transform) + return !frame.intersection(visibleRect).isEmpty } var focusRingView: (NSView & FocusRingDelegate)? { - _openSwiftUIUnimplementedFailure() + if delegatesFocusEffect { + return firstAncestor(ofType: FocusEffectDelegateResponder.self)?.focusRingView + } else { + return (hostView as? any PlatformFocusViewProvider)?.focusView as? (NSView & FocusRingDelegate) + } + } +} + +extension NSViewResponder: FocusResponder { + var platformItem: NSView? { + _openSwiftUIUnreachableCode() + } + + func setFocusRingView(_ view: (NSView & FocusRingDelegate)?) { + _openSwiftUIEmptyStub() + } +} + +extension BaseFocusResponder where Self: NSViewResponder { + var isEnabled: Bool { + _openSwiftUIUnreachableCode() } } -// TODO: FocusResponder #endif diff --git a/Sources/OpenSwiftUI/Event/Responder/PlatformViewResponderBase.swift b/Sources/OpenSwiftUI/Event/Responder/PlatformViewResponderBase.swift index 6ef42997c..471a4e6bd 100644 --- a/Sources/OpenSwiftUI/Event/Responder/PlatformViewResponderBase.swift +++ b/Sources/OpenSwiftUI/Event/Responder/PlatformViewResponderBase.swift @@ -27,6 +27,15 @@ class PlatformViewResponderBase: ViewResponder { weak var preferredFocusableView: PlatformView? + #if os(macOS) + var frame: CGRect? { + guard let hostView else { return .zero } + var frame = hostView.alignmentRect(forFrame: hostView.bounds) + frame.convert(to: .id(hostingViewCoordinateSpace), transform: helper.transform) + return frame + } + #endif + var platformViewIsEnabled: Bool { hostView != nil } diff --git a/Sources/OpenSwiftUI/Integration/Representable/Platform/PlatformViewHost.swift b/Sources/OpenSwiftUI/Integration/Representable/Platform/PlatformViewHost.swift index e042f9542..70e87f160 100644 --- a/Sources/OpenSwiftUI/Integration/Representable/Platform/PlatformViewHost.swift +++ b/Sources/OpenSwiftUI/Integration/Representable/Platform/PlatformViewHost.swift @@ -416,6 +416,15 @@ where Content: PlatformViewRepresentable { } } +extension PlatformViewHost: PlatformFocusViewProvider { + var focusView: PlatformView { + if let provider = Content.platformView(for: representedViewProvider) as? any PlatformFocusViewProvider { + return provider.focusView + } + return Content.platformView(for: representedViewProvider) + } +} + extension PlatformViewHost: SafeAreaHelperDelegate { #if os(macOS) var _safeAreaInsets: PlatformEdgeInsets {