Skip to content

Commit b1b4ca1

Browse files
authored
Add View.textSelectionEnabled modifier (#220)
* removed files that were not supposed to exist * Added UIKit & AppKit Text Selectability modifier * Added GtkBackend Support * winui support * reintroduced numberOfLines = 0 in UIKitBackend * implemented requested Changes Additionally I changed the default value of isTextSelectionEnabled to be set in the initializer, where the other values get set. Should help to get an impression of the default values faster than setting it in the property definition. * Added suggested AppKitBackend textSelection fix * Make it run on tvOS, prefer beberkas solution on merge textSelection is being ignored on tvOS * Formatting changes
1 parent 513458a commit b1b4ca1

File tree

10 files changed

+108
-12
lines changed

10 files changed

+108
-12
lines changed

Examples/Sources/GreetingGeneratorExample/GreetingGeneratorApp.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import SwiftCrossUI
1010
struct GreetingGeneratorApp: App {
1111
@State var name = ""
1212
@State var greetings: [String] = []
13+
@State var isGreetingSelectable = false
1314

1415
var body: some Scene {
1516
WindowGroup("Greeting Generator") {
@@ -26,9 +27,11 @@ struct GreetingGeneratorApp: App {
2627
}
2728
}
2829

30+
Toggle("Selectable Greeting", active: $isGreetingSelectable)
2931
if let latest = greetings.last {
3032
Text(latest)
3133
.padding(.top, 5)
34+
.textSelectionEnabled(isGreetingSelectable)
3235

3336
if greetings.count > 1 {
3437
Text("History:")

Sources/AppKitBackend/AppKitBackend.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,10 @@ public final class AppKitBackend: AppBackend {
573573
) {
574574
let field = textView as! NSTextField
575575
field.attributedStringValue = Self.attributedString(for: content, in: environment)
576+
if field.isSelectable && !environment.isTextSelectionEnabled {
577+
field.abortEditing()
578+
}
579+
field.isSelectable = environment.isTextSelectionEnabled
576580
}
577581

578582
public func createButton() -> Widget {

Sources/GtkBackend/GtkBackend.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -606,7 +606,7 @@ public final class GtkBackend: AppBackend {
606606
case .trailing:
607607
Justification.right
608608
}
609-
609+
textView.selectable = environment.isTextSelectionEnabled
610610
textView.css.clear()
611611
textView.css.set(properties: Self.cssProperties(for: environment))
612612
}

Sources/SwiftCrossUI/Environment/EnvironmentValues.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ public struct EnvironmentValues {
9595
/// The style of toggle to use.
9696
public var toggleStyle: ToggleStyle
9797

98+
/// Whether the text should be selectable. Set by ``View/textSelectionEnabled(_:)``.
99+
public var isTextSelectionEnabled: Bool
100+
98101
// Backing storage for extensible subscript
99102
private var extraValues: [ObjectIdentifier: Any]
100103

@@ -208,6 +211,7 @@ public struct EnvironmentValues {
208211
toggleStyle = .button
209212
isEnabled = true
210213
scrollDismissesKeyboardMode = .automatic
214+
isTextSelectionEnabled = false
211215
}
212216

213217
/// Returns a copy of the environment with the specified property set to the

Sources/SwiftCrossUI/Views/ForEach.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ extension ForEach where Items == [Int] {
2828
self.elements = Array(range)
2929
self.child = child
3030
}
31-
31+
3232
/// Creates a view that creates child views on demand based on a given Range
3333
@_disfavoredOverload
3434
public init(
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
extension View {
2+
/// Set selectability of contained text. Ignored on tvOS.
3+
public func textSelectionEnabled(_ isEnabled: Bool = true) -> some View {
4+
EnvironmentModifier(
5+
self,
6+
modification: { environment in
7+
environment.with(\.isTextSelectionEnabled, isEnabled)
8+
})
9+
}
10+
}

Sources/UIKitBackend/UIKitBackend+Control.swift

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -182,10 +182,8 @@ final class TappableWidget: ContainerWidget {
182182
}
183183
}
184184

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

191189
var hoverChangesHandler: ((Bool) -> Void)? {
@@ -211,8 +209,8 @@ final class HoverableWidget: ContainerWidget {
211209
default: break
212210
}
213211
}
214-
#endif
215-
}
212+
}
213+
#endif
216214

217215
@available(tvOS, unavailable)
218216
final class SliderWidget: WrapperWidget<UISlider> {

Sources/UIKitBackend/UIKitBackend+Passive.swift

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ extension UIKitBackend {
3636
}
3737

3838
public func createTextView() -> Widget {
39-
let widget = WrapperWidget<UILabel>()
39+
let widget = WrapperWidget<OptionallySelectableLabel>()
4040
widget.child.numberOfLines = 0
4141
return widget
4242
}
@@ -46,12 +46,13 @@ extension UIKitBackend {
4646
content: String,
4747
environment: EnvironmentValues
4848
) {
49-
let wrapper = textView as! WrapperWidget<UILabel>
49+
let wrapper = textView as! WrapperWidget<OptionallySelectableLabel>
5050
wrapper.child.overrideUserInterfaceStyle = environment.colorScheme.userInterfaceStyle
5151
wrapper.child.attributedText = UIKitBackend.attributedString(
5252
text: content,
5353
environment: environment
5454
)
55+
wrapper.child.isSelectable = environment.isTextSelectionEnabled
5556
}
5657

5758
public func size(
@@ -104,3 +105,77 @@ extension UIKitBackend {
104105
wrapper.child.image = .init(ciImage: ciImage)
105106
}
106107
}
108+
109+
// Inspired by https://medium.com/kinandcartacreated/making-uilabel-accessible-5f3d5c342df4
110+
// Thank you to Sam Dods for the base idea
111+
final class OptionallySelectableLabel: UILabel {
112+
var isSelectable: Bool = false
113+
114+
override init(frame: CGRect) {
115+
super.init(frame: frame)
116+
setupTextSelection()
117+
}
118+
119+
required init?(coder aDecoder: NSCoder) {
120+
super.init(coder: aDecoder)
121+
setupTextSelection()
122+
}
123+
124+
override var canBecomeFirstResponder: Bool {
125+
isSelectable
126+
}
127+
128+
private func setupTextSelection() {
129+
#if !os(tvOS)
130+
let longPress = UILongPressGestureRecognizer(
131+
target: self, action: #selector(didLongPress))
132+
addGestureRecognizer(longPress)
133+
isUserInteractionEnabled = true
134+
#endif
135+
}
136+
137+
@objc private func didLongPress(_ gesture: UILongPressGestureRecognizer) {
138+
#if !os(tvOS)
139+
guard
140+
isSelectable,
141+
gesture.state == .began,
142+
let text = self.attributedText?.string,
143+
!text.isEmpty
144+
else {
145+
return
146+
}
147+
window?.endEditing(true)
148+
guard becomeFirstResponder() else { return }
149+
150+
let menu = UIMenuController.shared
151+
if !menu.isMenuVisible {
152+
menu.showMenu(from: self, rect: textRect())
153+
}
154+
#endif
155+
}
156+
157+
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
158+
return action == #selector(copy(_:))
159+
}
160+
161+
private func textRect() -> CGRect {
162+
let inset: CGFloat = -4
163+
return textRect(forBounds: bounds, limitedToNumberOfLines: numberOfLines)
164+
.insetBy(dx: inset, dy: inset)
165+
}
166+
167+
private func cancelSelection() {
168+
#if !os(tvOS)
169+
let menu = UIMenuController.shared
170+
menu.hideMenu(from: self)
171+
#endif
172+
}
173+
174+
@objc override func copy(_ sender: Any?) {
175+
#if !os(tvOS)
176+
cancelSelection()
177+
let board = UIPasteboard.general
178+
board.string = text
179+
#endif
180+
}
181+
}

Sources/UIKitBackend/UIKitBackend+WebView.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
#if !os(tvOS)
2-
import SwiftCrossUI
1+
import SwiftCrossUI
2+
3+
#if canImport(WebKit)
34
import WebKit
45

56
extension UIKitBackend {

Sources/WinUIBackend/WinUIBackend.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,7 @@ public final class WinUIBackend: AppBackend {
578578
) {
579579
let block = textView as! TextBlock
580580
block.text = content
581+
block.isTextSelectionEnabled = environment.isTextSelectionEnabled
581582
missing("font design handling (monospace vs normal)")
582583
environment.apply(to: block)
583584
}

0 commit comments

Comments
 (0)