diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index 62f9e4b46d..c18a11c733 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -861,6 +861,8 @@ public final class AppKitBackend: AppBackend { return scrollView } + public func updateScrollContainer(_ scrollView: Widget, environment: EnvironmentValues) {} + public func setScrollBarPresence( ofScrollContainer scrollView: Widget, hasVerticalScrollBar: Bool, diff --git a/Sources/CursesBackend/CursesBackend.swift b/Sources/CursesBackend/CursesBackend.swift index 311914aaa3..4075678593 100644 --- a/Sources/CursesBackend/CursesBackend.swift +++ b/Sources/CursesBackend/CursesBackend.swift @@ -84,6 +84,8 @@ public final class CursesBackend: AppBackend { public func setSpacing(ofHStack container: Widget, to spacing: Int) {} + public func updateScrollContainer(_ scrollView: Widget, environment: EnvironmentValues) {} + public func createTextView() -> Widget { let label = Label("") label.width = Dim.fill() diff --git a/Sources/Gtk3Backend/Gtk3Backend.swift b/Sources/Gtk3Backend/Gtk3Backend.swift index 9b8c6411d4..b80d766762 100644 --- a/Sources/Gtk3Backend/Gtk3Backend.swift +++ b/Sources/Gtk3Backend/Gtk3Backend.swift @@ -539,6 +539,8 @@ public final class Gtk3Backend: AppBackend { return scrollView } + public func updateScrollContainer(_ scrollView: Widget, environment: EnvironmentValues) {} + public func setScrollBarPresence( ofScrollContainer scrollView: Widget, hasVerticalScrollBar: Bool, diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index 094186f916..d15e4ab50c 100644 --- a/Sources/GtkBackend/GtkBackend.swift +++ b/Sources/GtkBackend/GtkBackend.swift @@ -503,6 +503,8 @@ public final class GtkBackend: AppBackend { return scrollView } + public func updateScrollContainer(_ scrollView: Widget, environment: EnvironmentValues) {} + public func setScrollBarPresence( ofScrollContainer scrollView: Widget, hasVerticalScrollBar: Bool, diff --git a/Sources/LVGLBackend/LVGLBackend.swift b/Sources/LVGLBackend/LVGLBackend.swift index 918f8df45b..ada701df38 100644 --- a/Sources/LVGLBackend/LVGLBackend.swift +++ b/Sources/LVGLBackend/LVGLBackend.swift @@ -156,6 +156,8 @@ public final class LVGLBackend: AppBackend { } } + public func updateScrollContainer(_ scrollView: Widget, environment: EnvironmentValues) {} + public func createTextView() -> Widget { return Widget { parent in let label = LVLabel(with: parent) diff --git a/Sources/QtBackend/QtBackend.swift b/Sources/QtBackend/QtBackend.swift index e4a35826c5..59ed595bf0 100644 --- a/Sources/QtBackend/QtBackend.swift +++ b/Sources/QtBackend/QtBackend.swift @@ -108,6 +108,8 @@ public struct QtBackend: AppBackend { } } + public func updateScrollContainer(_ scrollView: Widget, environment: EnvironmentValues) {} + public func setSpacing(ofHStack widget: Widget, to spacing: Int) { (widget.layout as! QHBoxLayout).spacing = Int32(spacing) } diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index 634db2f9c4..e074fdf76b 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -264,6 +264,18 @@ public protocol AppBackend { /// Creates a scrollable single-child container wrapping the given widget. func createScrollContainer(for child: Widget) -> Widget + /// Updates a scroll container with environment-specific values. + /// + /// This method is primarily used on iOS to apply environment changes + /// that affect the scroll view’s behavior, such as keyboard dismissal mode. + /// + /// - Parameters: + /// - scrollView: The scroll container widget previously created by `createScrollContainer(for:)`. + /// - environment: The current `EnvironmentValues` to apply. + func updateScrollContainer( + _ scrollView: Widget, + environment: EnvironmentValues + ) /// Sets the presence of scroll bars along each axis of a scroll container. func setScrollBarPresence( ofScrollContainer scrollView: Widget, @@ -741,6 +753,10 @@ extension AppBackend { todo() } + public func updateScrollContainer(_ scrollView: Widget, environment: EnvironmentValues) { + todo() + } + public func setScrollBarPresence( ofScrollContainer scrollView: Widget, hasVerticalScrollBar: Bool, diff --git a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift index 3d709f9aa2..929643f4aa 100644 --- a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift +++ b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift @@ -78,6 +78,9 @@ public struct EnvironmentValues { /// Whether user interaction is enabled. Set by ``View/disabled(_:)``. public var isEnabled: Bool + /// The way that scrollable content interacts with the software keyboard. + public var scrollDismissesKeyboardMode: ScrollDismissesKeyboardMode + /// Called by view graph nodes when they resize due to an internal state /// change and end up changing size. Each view graph node sets its own /// handler when passing the environment on to its children, setting up @@ -196,6 +199,7 @@ public struct EnvironmentValues { listStyle = .default toggleStyle = .button isEnabled = true + scrollDismissesKeyboardMode = .automatic } /// Returns a copy of the environment with the specified property set to the diff --git a/Sources/SwiftCrossUI/Values/ScrollDismissesKeyboardMode.swift b/Sources/SwiftCrossUI/Values/ScrollDismissesKeyboardMode.swift new file mode 100644 index 0000000000..1dd7aef240 --- /dev/null +++ b/Sources/SwiftCrossUI/Values/ScrollDismissesKeyboardMode.swift @@ -0,0 +1,28 @@ +/// The ways that scrollable content can interact with the software keyboard. +/// +/// Use this type in a call to the ``View/scrollDismissesKeyboard(_:)`` +/// modifier to specify the dismissal behavior of scrollable views. +public enum ScrollDismissesKeyboardMode: Sendable { + /// Determine the mode automatically based on the surrounding context. + /// + /// Currently, this behaves the same as ``ScrollDismissesKeyboardMode/interactively``. + /// In the future, it may adapt dynamically based on the context, similar to how + /// SwiftUI's `.automatic` works (e.g., using `.interactively` in some views and + /// `.immediately` in others). Using this value avoids source-breaking changes later on. + case automatic + + /// Dismiss the keyboard as soon as scrolling starts. + case immediately + + /// Enable people to interactively dismiss the keyboard as part of the + /// scroll operation. + /// + /// The software keyboard's position tracks the gesture that drives the + /// scroll operation if the gesture crosses into the keyboard's area of the + /// display. People can dismiss the keyboard by scrolling it off the + /// display, or reverse the direction of the scroll to cancel the dismissal. + case interactively + + /// Never dismiss the keyboard automatically as a result of scrolling. + case never +} diff --git a/Sources/SwiftCrossUI/Views/Modifiers/ScrollDismissesKeyboardModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/ScrollDismissesKeyboardModifier.swift new file mode 100644 index 0000000000..0655a8e907 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Modifiers/ScrollDismissesKeyboardModifier.swift @@ -0,0 +1,41 @@ +extension View { + /// Configures the behavior in which scrollable content interacts with + /// the software keyboard. + /// + /// You use this modifier to customize how scrollable content interacts + /// with the software keyboard. For example, you can specify a value of + /// ``ScrollDismissesKeyboardMode/immediately`` to indicate that you + /// would like scrollable content to immediately dismiss the keyboard if + /// present when a scroll drag gesture begins. + /// + /// @State private var text = "" + /// + /// ScrollView { + /// TextField("Prompt", text: $text) + /// ForEach(0 ..< 50) { index in + /// Text("\(index)") + /// .padding() + /// } + /// } + /// .scrollDismissesKeyboard(.immediately) + /// + /// You can also use this modifier to customize the keyboard dismissal + /// behavior for other kinds of scrollable views, like a ``List`` or a + /// ``TextEditor``. + /// + /// By default, scrollable content dismisses the keyboard interactively as the user scrolls. + /// Pass a different value of ``ScrollDismissesKeyboardMode`` to change this behavior. + /// For example, use ``ScrollDismissesKeyboardMode/never`` to prevent the keyboard from + /// dismissing automatically. Note that ``TextEditor`` may still use a different + /// default to preserve expected editing behavior. + /// + /// - Parameter mode: The keyboard dismissal mode that scrollable content + /// uses. + /// + /// - Returns: A view that uses the specified keyboard dismissal mode. + public func scrollDismissesKeyboard(_ mode: ScrollDismissesKeyboardMode) -> some View { + EnvironmentModifier(self) { environment in + environment.with(\.scrollDismissesKeyboardMode, mode) + } + } +} diff --git a/Sources/SwiftCrossUI/Views/ScrollView.swift b/Sources/SwiftCrossUI/Views/ScrollView.swift index d375c1fa85..b3d7f1bc51 100644 --- a/Sources/SwiftCrossUI/Views/ScrollView.swift +++ b/Sources/SwiftCrossUI/Views/ScrollView.swift @@ -143,6 +143,7 @@ public struct ScrollView: TypeSafeView, View { hasVerticalScrollBar: hasVerticalScrollBar, hasHorizontalScrollBar: hasHorizontalScrollBar ) + backend.updateScrollContainer(widget, environment: environment) } else { finalResult = childResult } diff --git a/Sources/UIKitBackend/UIKitBackend+Container.swift b/Sources/UIKitBackend/UIKitBackend+Container.swift index 5375a24aa4..4f28290ad8 100644 --- a/Sources/UIKitBackend/UIKitBackend+Container.swift +++ b/Sources/UIKitBackend/UIKitBackend+Container.swift @@ -56,6 +56,21 @@ final class ScrollWidget: ContainerWidget { scrollView.showsVerticalScrollIndicator = hasVerticalScrollBar scrollView.showsHorizontalScrollIndicator = hasHorizontalScrollBar } + + public func updateScrollContainer(environment: EnvironmentValues) { + #if os(iOS) + scrollView.keyboardDismissMode = switch environment.scrollDismissesKeyboardMode { + case .automatic: + .interactive + case .immediately: + .onDrag + case .interactively: + .interactive + case .never: + .none + } + #endif + } } extension UIKitBackend { @@ -121,6 +136,11 @@ extension UIKitBackend { ScrollWidget(child: child) } + public func updateScrollContainer(_ scrollView: Widget, environment: EnvironmentValues) { + let scrollViewWidget = scrollView as! ScrollWidget + scrollViewWidget.updateScrollContainer(environment: environment) + } + public func setScrollBarPresence( ofScrollContainer scrollView: Widget, hasVerticalScrollBar: Bool, diff --git a/Sources/UIKitBackend/UIKitBackend+Control.swift b/Sources/UIKitBackend/UIKitBackend+Control.swift index 0b813a8dc8..55e60d1921 100644 --- a/Sources/UIKitBackend/UIKitBackend+Control.swift +++ b/Sources/UIKitBackend/UIKitBackend+Control.swift @@ -317,6 +317,18 @@ extension UIKitBackend { } else { textEditorWidget.child.inputAccessoryView = nil } + + textEditorWidget.child.alwaysBounceVertical = environment.scrollDismissesKeyboardMode != .never + textEditorWidget.child.keyboardDismissMode = switch environment.scrollDismissesKeyboardMode { + case .automatic: + textEditorWidget.child.inputAccessoryView == nil ? .interactive : .interactiveWithAccessory + case .immediately: + textEditorWidget.child.inputAccessoryView == nil ? .onDrag : .onDragWithAccessory + case .interactively: + textEditorWidget.child.inputAccessoryView == nil ? .interactive : .interactiveWithAccessory + case .never: + .none + } #endif } diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index af3ed53145..fe411f10da 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -616,6 +616,8 @@ public final class WinUIBackend: AppBackend { return scrollViewer } + public func updateScrollContainer(_ scrollView: Widget, environment: EnvironmentValues) {} + public func setScrollBarPresence( ofScrollContainer scrollView: Widget, hasVerticalScrollBar: Bool,