diff --git a/Examples/Sources/GreetingGeneratorExample/GreetingGeneratorApp.swift b/Examples/Sources/GreetingGeneratorExample/GreetingGeneratorApp.swift index 1f1b6f9e92..fa4b984eff 100644 --- a/Examples/Sources/GreetingGeneratorExample/GreetingGeneratorApp.swift +++ b/Examples/Sources/GreetingGeneratorExample/GreetingGeneratorApp.swift @@ -10,6 +10,7 @@ import SwiftCrossUI struct GreetingGeneratorApp: App { @State var name = "" @State var greetings: [String] = [] + @State var isGreetingSelectable = false var body: some Scene { WindowGroup("Greeting Generator") { @@ -26,9 +27,11 @@ struct GreetingGeneratorApp: App { } } + Toggle("Selectable Greeting", active: $isGreetingSelectable) if let latest = greetings.last { Text(latest) .padding(.top, 5) + .textSelectionEnabled(isGreetingSelectable) if greetings.count > 1 { Text("History:") diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index 4f23de39d1..1140d6e98b 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -573,6 +573,7 @@ public final class AppKitBackend: AppBackend { ) { let field = textView as! NSTextField field.attributedStringValue = Self.attributedString(for: content, in: environment) + field.isSelectable = environment.isTextSelectionEnabled } public func createButton() -> Widget { diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index 5bc07226c1..418014c1c0 100644 --- a/Sources/GtkBackend/GtkBackend.swift +++ b/Sources/GtkBackend/GtkBackend.swift @@ -606,7 +606,7 @@ public final class GtkBackend: AppBackend { case .trailing: Justification.right } - + textView.selectable = environment.isTextSelectionEnabled textView.css.clear() textView.css.set(properties: Self.cssProperties(for: environment)) } diff --git a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift index 475828ca63..ffb03d5d94 100644 --- a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift +++ b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift @@ -95,6 +95,9 @@ public struct EnvironmentValues { /// The style of toggle to use. public var toggleStyle: ToggleStyle + /// Whether the text should be selectable. Set by ``View/textSelectionEnabled(_:)``. + public var isTextSelectionEnabled: Bool + // Backing storage for extensible subscript private var extraValues: [ObjectIdentifier: Any] @@ -208,6 +211,7 @@ public struct EnvironmentValues { toggleStyle = .button isEnabled = true scrollDismissesKeyboardMode = .automatic + isTextSelectionEnabled = false } /// Returns a copy of the environment with the specified property set to the diff --git a/Sources/SwiftCrossUI/Views/Modifiers/TextSelectionModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/TextSelectionModifier.swift new file mode 100644 index 0000000000..98c1208ca9 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Modifiers/TextSelectionModifier.swift @@ -0,0 +1,10 @@ +extension View { + /// Set selectability of contained text. + public func textSelectionEnabled(_ isEnabled: Bool = true) -> some View { + EnvironmentModifier( + self, + modification: { environment in + environment.with(\.isTextSelectionEnabled, isEnabled) + }) + } +} diff --git a/Sources/UIKitBackend/UIKitBackend+Passive.swift b/Sources/UIKitBackend/UIKitBackend+Passive.swift index 235e7b893c..4e3b9e0f8f 100644 --- a/Sources/UIKitBackend/UIKitBackend+Passive.swift +++ b/Sources/UIKitBackend/UIKitBackend+Passive.swift @@ -36,7 +36,7 @@ extension UIKitBackend { } public func createTextView() -> Widget { - let widget = WrapperWidget() + let widget = WrapperWidget() widget.child.numberOfLines = 0 return widget } @@ -46,12 +46,13 @@ extension UIKitBackend { content: String, environment: EnvironmentValues ) { - let wrapper = textView as! WrapperWidget + let wrapper = textView as! WrapperWidget wrapper.child.overrideUserInterfaceStyle = environment.colorScheme.userInterfaceStyle wrapper.child.attributedText = UIKitBackend.attributedString( text: content, environment: environment ) + wrapper.child.isSelectable = environment.isTextSelectionEnabled } public func size( @@ -104,3 +105,68 @@ extension UIKitBackend { wrapper.child.image = .init(ciImage: ciImage) } } + +// Inspired by https://medium.com/kinandcartacreated/making-uilabel-accessible-5f3d5c342df4 +// Thank you to Sam Dods for the base idea +final class OptionallySelectableLabel: UILabel { + var isSelectable: Bool = false + + override init(frame: CGRect) { + super.init(frame: frame) + setupTextSelection() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + setupTextSelection() + } + + override var canBecomeFirstResponder: Bool { + isSelectable + } + + private func setupTextSelection() { + let longPress = UILongPressGestureRecognizer(target: self, action: #selector(didLongPress)) + addGestureRecognizer(longPress) + isUserInteractionEnabled = true + } + + @objc private func didLongPress(_ gesture: UILongPressGestureRecognizer) { + guard + isSelectable, + gesture.state == .began, + let text = self.attributedText?.string, + !text.isEmpty + else { + return + } + window?.endEditing(true) + guard becomeFirstResponder() else { return } + + let menu = UIMenuController.shared + if !menu.isMenuVisible { + menu.showMenu(from: self, rect: textRect()) + } + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + return action == #selector(copy(_:)) + } + + private func textRect() -> CGRect { + let inset: CGFloat = -4 + return textRect(forBounds: bounds, limitedToNumberOfLines: numberOfLines) + .insetBy(dx: inset, dy: inset) + } + + private func cancelSelection() { + let menu = UIMenuController.shared + menu.hideMenu(from: self) + } + + @objc override func copy(_ sender: Any?) { + cancelSelection() + let board = UIPasteboard.general + board.string = text + } +} diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index a54a1a8625..882285118a 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -578,6 +578,7 @@ public final class WinUIBackend: AppBackend { ) { let block = textView as! TextBlock block.text = content + block.isTextSelectionEnabled = environment.isTextSelectionEnabled missing("font design handling (monospace vs normal)") environment.apply(to: block) }