Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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:")
Expand Down
4 changes: 4 additions & 0 deletions Sources/AppKitBackend/AppKitBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,10 @@ public final class AppKitBackend: AppBackend {
) {
let field = textView as! NSTextField
field.attributedStringValue = Self.attributedString(for: content, in: environment)
if field.isSelectable && !environment.isTextSelectionEnabled {
field.abortEditing()
}
field.isSelectable = environment.isTextSelectionEnabled
}

public func createButton() -> Widget {
Expand Down
2 changes: 1 addition & 1 deletion Sources/GtkBackend/GtkBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/SwiftCrossUI/Environment/EnvironmentValues.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftCrossUI/Views/ForEach.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ extension ForEach where Items == [Int] {
self.elements = Array(range)
self.child = child
}

/// Creates a view that creates child views on demand based on a given Range
@_disfavoredOverload
public init(
Expand Down
10 changes: 10 additions & 0 deletions Sources/SwiftCrossUI/Views/Modifiers/TextSelectionModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
extension View {
/// Set selectability of contained text. Ignored on tvOS.
public func textSelectionEnabled(_ isEnabled: Bool = true) -> some View {
EnvironmentModifier(
self,
modification: { environment in
environment.with(\.isTextSelectionEnabled, isEnabled)
})
}
}
10 changes: 4 additions & 6 deletions Sources/UIKitBackend/UIKitBackend+Control.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,8 @@ final class TappableWidget: ContainerWidget {
}
}

@available(tvOS, unavailable)
final class HoverableWidget: ContainerWidget {
// So much as attempting to reference UIHoverGestureRecognizer here results in a linker error on tvOS.
#if !os(tvOS)
#if !os(tvOS)
final class HoverableWidget: ContainerWidget {
private var hoverGestureRecognizer: UIHoverGestureRecognizer?

var hoverChangesHandler: ((Bool) -> Void)? {
Expand All @@ -214,8 +212,8 @@ final class HoverableWidget: ContainerWidget {
default: break
}
}
#endif
}
}
#endif

@available(tvOS, unavailable)
final class SliderWidget: WrapperWidget<UISlider> {
Expand Down
79 changes: 77 additions & 2 deletions Sources/UIKitBackend/UIKitBackend+Passive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
}

public func createTextView() -> Widget {
let widget = WrapperWidget<UILabel>()
let widget = WrapperWidget<OptionallySelectableLabel>()
widget.child.numberOfLines = 0
return widget
}
Expand All @@ -46,12 +46,13 @@
content: String,
environment: EnvironmentValues
) {
let wrapper = textView as! WrapperWidget<UILabel>
let wrapper = textView as! WrapperWidget<OptionallySelectableLabel>
wrapper.child.overrideUserInterfaceStyle = environment.colorScheme.userInterfaceStyle
wrapper.child.attributedText = UIKitBackend.attributedString(
text: content,
environment: environment
)
wrapper.child.isSelectable = environment.isTextSelectionEnabled
}

public func size(
Expand Down Expand Up @@ -104,3 +105,77 @@
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() {
#if !os(tvOS)
let longPress = UILongPressGestureRecognizer(
target: self, action: #selector(didLongPress))
addGestureRecognizer(longPress)
isUserInteractionEnabled = true
#endif
}

@objc private func didLongPress(_ gesture: UILongPressGestureRecognizer) {
#if !os(tvOS)
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

Check warning on line 150 in Sources/UIKitBackend/UIKitBackend+Passive.swift

View workflow job for this annotation

GitHub Actions / uikit (Vision)

'UIMenuController' was deprecated in visionOS 1.0: UIMenuController is deprecated. Use UIEditMenuInteraction instead.
if !menu.isMenuVisible {
menu.showMenu(from: self, rect: textRect())
}
#endif
}

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() {
#if !os(tvOS)
let menu = UIMenuController.shared

Check warning on line 169 in Sources/UIKitBackend/UIKitBackend+Passive.swift

View workflow job for this annotation

GitHub Actions / uikit (Vision)

'UIMenuController' was deprecated in visionOS 1.0: UIMenuController is deprecated. Use UIEditMenuInteraction instead.
menu.hideMenu(from: self)
#endif
}

@objc override func copy(_ sender: Any?) {
#if !os(tvOS)
cancelSelection()
let board = UIPasteboard.general
board.string = text
#endif
}
}
5 changes: 3 additions & 2 deletions Sources/UIKitBackend/UIKitBackend+WebView.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#if !os(tvOS)
import SwiftCrossUI
import SwiftCrossUI

#if canImport(WebKit)
import WebKit

extension UIKitBackend {
Expand Down
1 change: 1 addition & 0 deletions Sources/WinUIBackend/WinUIBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading