From 9f5b7602c11a92cb83704feb3946fe6b4f584fa5 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 15 Jan 2024 04:57:35 -0800 Subject: [PATCH] more implementations --- modules/expo-selectable-text/README.md | 79 ++++++++++++------- modules/expo-selectable-text/index.ts | 6 +- .../ios/ExpoUITextView.swift | 37 ++++++++- .../ios/ExpoUITextViewModule.swift | 42 +++++++++- .../expo-selectable-text/ios/PropTypes.swift | 49 ++++++++++++ .../ios/TextSegment.swift | 15 ---- .../src/ExpoUITextView.tsx | 27 ++++--- .../src/ExpoUITextView.types.ts | 2 +- src/view/com/util/text/Text.tsx | 6 +- 9 files changed, 197 insertions(+), 66 deletions(-) create mode 100644 modules/expo-selectable-text/ios/PropTypes.swift delete mode 100644 modules/expo-selectable-text/ios/TextSegment.swift diff --git a/modules/expo-selectable-text/README.md b/modules/expo-selectable-text/README.md index a100867e51..87891bafbe 100644 --- a/modules/expo-selectable-text/README.md +++ b/modules/expo-selectable-text/README.md @@ -1,25 +1,9 @@ # Expo Selectable Text -This module creates a wrapper `` for React Native's `` component. Text components are -parsed and rendered using UITextView instead of UILabel, allowing for text selection. +This module is a drop-in replacement for React Native's `Text` component. Simply replace `` with ``. ## Usage -In most cases, you can simply use like so: - -```tsx - - Here is some text. - -``` - -You may also have nested `` components inside the `` block. For example: -```tsx - - Here is some text. {Alert.alert('Press!')}}>And this is a link. - -``` - Note that nested components should always use `` and not ``. Only the outermost `` should be replaced with ``. @@ -34,19 +18,54 @@ podspec inside of this directory. ## Technical -Whenever we use the SelectableText component, we then loop over each of the children (or just the text if the child -is typeof Text). We create "segments" of text based on this. +React Native's `Text` component allows for "infinite" nesting of further `Text` components. To make a true "drop-in", +we want to do the same thing. -The native side renders a *single* UITextView and adds each of the text segments to the view. Using NSAttributedStrings, -we can add the styles for each segment individually (falling back to the root styles). +To achieve this, we first need to handle determining if we are dealing with an ancestor or root `UITextView` component. +We can implement similar logic to the `Text` component [see Text.js](https://github.com/facebook/react-native/blob/7f2529de7bc9ab1617eaf571e950d0717c3102a6/packages/react-native/Libraries/Text/Text.js). -There's a few ways we could go about handling presses. For one, we could create fake URLs to add to each -NSAttributedString and handle their presses. However, this has a few downsides: -1. We can't support long presses this way without adding an additional gesture recognizer. -2. The presses actually are not "instant". The default gesture for these links requires a slighly longer press than just -a tap, so we'd end up needing to modify this recognizer anyway. +We create a context that contains a boolean to tell us if we have already rendered the root `UITextView`. We also store +the root styles so that we can apply those styles if the ancestor `UITextView`s have not overwritten those styles. -As such, we create a gesture recognizer for the entire UITextView. The recognizer determines the text at the position of -the press, determines which segment it is, and sends an `onTextPress` event to the JS side along with the index of the -segment. On the JS side, we have our array of segments (that included the `onPress` event from the `Text`/`SelectableText`) -that we can now call based on the index from the native event. +All of our children are placed into `ExpoUITextViewRoot`, which is the main native view that will display the native +`UITextView`. There are no styles that need to be applied to this view, as we will be updating the size of the view +dynamically based on the text size. + +We next map each child into the view. We have to be careful here to check if the child's `children` prop is a string. If +it is, that means we have encountered what was once an RN `Text` component. RN doesn't let us pass plain text as +children outside of `Text`, so we instead just pass the text into the `text` prop on the `ExpoUITextViewChild` native +view. We continue down the tree, until we run out of children. + +On the native side, we have two view types: `ExpoUITextView` and `ExpoUITextViewChild`. Again, the `ExpoUITextView` +contains the `UITextView`, and the `ExpoUITextViewChild` views are invisible, only allowing us to access their props. + +Each time a new subview is added to the root view, we check its type. If it is of `ExpoUITextViewChild`, we add it to +our subviews. We prefer to keep these "rendered" in as subviews so that React can manage their order. This also keeps +them stateful. Again though, these views are not visible and do not actually render a view. + +We also update the `UITextView`'s text each time new subviews are added. We create a `NSAttributedString` that contains +the text of each child and applies the styles to the string. There is near parity to base RN `TextStyle`, however there +may be a few discrepancies. As I find those I'll correct them. + +As for `Text` props, the following props are implemented: + +- All accessibility props +- `allowFontScaling` +- `adjustsFontSizeToFit` +- `ellipsizeMode` +- `numberOfLines` +- `onLayout` +- `onPress` +- `onTextLayout` +- `selectable` + +All `ViewStyle` props will apply to the root `UITextView`. Individual children will respect these `TextStyle` styles: + +- `color` +- `fontSize` +- `fontStyle` +- `fontWeight` +- `fontVariant` +- `letterSpacing` +- `lineHeight` +- `textDecorationLine` diff --git a/modules/expo-selectable-text/index.ts b/modules/expo-selectable-text/index.ts index b3e74bda79..d9eeb63ff7 100644 --- a/modules/expo-selectable-text/index.ts +++ b/modules/expo-selectable-text/index.ts @@ -1,4 +1,4 @@ -import ExpoSelectableTextView from './src/ExpoUITextView' -import {ExpoProTextViewProps} from './src/ExpoUITextView.types' +import ExpoUITextView from './src/ExpoUITextView' +import {ExpoUITextViewProps} from './src/ExpoUITextView.types' -export {ExpoSelectableTextView as SelectableText, type ExpoProTextViewProps} +export {ExpoUITextView as UITextView, type ExpoUITextViewProps} diff --git a/modules/expo-selectable-text/ios/ExpoUITextView.swift b/modules/expo-selectable-text/ios/ExpoUITextView.swift index 000b14d8ef..04fcf67da0 100644 --- a/modules/expo-selectable-text/ios/ExpoUITextView.swift +++ b/modules/expo-selectable-text/ios/ExpoUITextView.swift @@ -6,6 +6,10 @@ class ExpoUITextView: ExpoView { var tapGestureRecognizer: UITapGestureRecognizer? + let onTextLayout = EventDispatcher() + + // Props + public required init(appContext: AppContext? = nil) { if #available(iOS 16.0, *) { textView = UITextView(usingTextLayoutManager: false) @@ -18,8 +22,6 @@ class ExpoUITextView: ExpoView { textView.isEditable = false textView.isScrollEnabled = false textView.backgroundColor = .clear - textView.textContainer.lineBreakMode = .byTruncatingTail - textView.isSelectable = true // Remove all of the padding from the view textView.textContainerInset = UIEdgeInsets.zero @@ -34,6 +36,14 @@ class ExpoUITextView: ExpoView { tapGestureRecognizer.isEnabled = true self.tapGestureRecognizer = tapGestureRecognizer textView.addGestureRecognizer(tapGestureRecognizer) + + // Listen for dynamic type changes + NotificationCenter.default.addObserver( + self, + selector: #selector(preferredContentSizeChanged(_:)), + name: UIContentSizeCategory.didChangeNotification, + object: nil + ) } // Update children whenever new react subviews are added @@ -66,6 +76,23 @@ class ExpoUITextView: ExpoView { textView.frame.size = size self.appContext?.reactBridge?.uiManager.setSize(size, for: self) + // Get each line and call onTextLayout + var lines: [String] = [] + textView.layoutManager.enumerateLineFragments( + forGlyphRange: NSRange(location: 0, length: textView.attributedText.length)) + { (rect, usedRect, textContainer, glyphRange, stop) in + let characterRange = self.textView.layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) + let line = (self.textView.text as NSString).substring(with: characterRange) + lines.append(line) + } + + onTextLayout([ + "lines": lines + ]) + } + + @objc func preferredContentSizeChanged(_ notification: Notification) { + self.setText() } @IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void { @@ -139,10 +166,14 @@ class ExpoUITextView: ExpoView { return } + let scaledFontSize = self.textView.adjustsFontForContentSizeCategory ? + UIFontMetrics.default.scaledValue(for: child.style?.fontSize ?? 12.0) : + child.style?.fontSize ?? 12.0 + // Set some generic attributes that don't need ranges let attributes: [NSAttributedString.Key:Any] = [ .font: UIFont.systemFont( - ofSize: child.style?.fontSize ?? 12.0, + ofSize: scaledFontSize, weight: child.style?.fontWeight?.toFontWeight() ?? .regular ), .foregroundColor: TextUtil.hexToUIColor(hex: child.style?.color), diff --git a/modules/expo-selectable-text/ios/ExpoUITextViewModule.swift b/modules/expo-selectable-text/ios/ExpoUITextViewModule.swift index 8e1ef09825..e9702c6ad8 100644 --- a/modules/expo-selectable-text/ios/ExpoUITextViewModule.swift +++ b/modules/expo-selectable-text/ios/ExpoUITextViewModule.swift @@ -2,9 +2,47 @@ import ExpoModulesCore public class ExpoUITextViewModule: Module { public func definition() -> ModuleDefinition { - Name("ExpoSelectableText") + Name("ExpoUITextView") View(ExpoUITextView.self) { + Events("onViewLayout", "onTextLayout") + + Prop("accessibilityHint") { (view: ExpoUITextView, prop: String) in + view.textView.accessibilityHint = prop + } + Prop("accessibilityLanguage") { (view: ExpoUITextView, prop: String) in + view.textView.accessibilityLanguage = prop + } + Prop("accessibilityLabel") { (view: ExpoUITextView, prop: String) in + view.textView.accessibilityLabel = prop + } + Prop("accessibilityRole") { (view: ExpoUITextView, prop: String) in + view.textView.accessibilityRole = prop + } + Prop("accessibilityState") { (view: ExpoUITextView, prop: AccessibilityState) in + view.textView.accessibilityState = prop.toAccessibilityState() + } + Prop("accessibilityValue") { (view: ExpoUITextView, prop: AccessibilityValue) in + view.textView.accessibilityValue = prop.text + } + Prop("accessibilityViewIsModal") { (view: ExpoUITextView, prop: Bool) in + view.textView.accessibilityViewIsModal = prop + } + Prop("accessibilityElementsHidden") { (view: ExpoUITextView, prop: Bool) in + view.textView.accessibilityElementsHidden = prop + } + Prop("allowFontScaling") { (view: ExpoUITextView, prop: Bool) in + view.textView.adjustsFontForContentSizeCategory = prop + } + Prop("numberOfLines") { (view: ExpoUITextView, prop: Int) in + view.textView.textContainer.maximumNumberOfLines = prop + } + Prop("ellipsizeMode") { (view: ExpoUITextView, prop: EllipsizeMode) in + view.textView.textContainer.lineBreakMode = prop.toLineBreakMode() + } + Prop("selectable") { (view: ExpoUITextView, prop: Bool) in + view.textView.isSelectable = prop + } } } } @@ -15,7 +53,7 @@ public class ExpoUITextViewModule: Module { */ public class ExpoUITextViewChildModule: Module { public func definition() -> ModuleDefinition { - Name("ExpoTextChild") + Name("ExpoUITextViewChild") View(ExpoUITextViewChild.self) { Events("onTextPress") diff --git a/modules/expo-selectable-text/ios/PropTypes.swift b/modules/expo-selectable-text/ios/PropTypes.swift new file mode 100644 index 0000000000..f68be5c2ef --- /dev/null +++ b/modules/expo-selectable-text/ios/PropTypes.swift @@ -0,0 +1,49 @@ +import ExpoModulesCore + +struct AccessibilityState: Record { + @Field + var disabled: Bool? + @Field + var selected: Bool? + @Field + var checked: Bool? + @Field + var busy: Bool? + @Field + var expanded: Bool? + + func toAccessibilityState() -> [String: Any] { + return self.toDictionary() + } +} + +struct AccessibilityValue: Record { + @Field + var min: Int? + @Field + var max: Int? + @Field + var now: Int? + @Field + var text: String? +} + +enum EllipsizeMode: String, Enumerable { + case head + case middle + case tail + case clip + + func toLineBreakMode() -> NSLineBreakMode { + switch self { + case .head: + return .byTruncatingHead + case .middle: + return .byTruncatingMiddle + case .tail: + return .byTruncatingTail + case .clip: + return .byClipping + } + } +} diff --git a/modules/expo-selectable-text/ios/TextSegment.swift b/modules/expo-selectable-text/ios/TextSegment.swift deleted file mode 100644 index 3542cf73ea..0000000000 --- a/modules/expo-selectable-text/ios/TextSegment.swift +++ /dev/null @@ -1,15 +0,0 @@ -import ExpoModulesCore - -struct TextSegments: Record { - @Field - var segments: Array -} - -struct TextSegment: Record { - @Field - var index: Int - @Field - var text: String - @Field - var style: TextStyle? -} diff --git a/modules/expo-selectable-text/src/ExpoUITextView.tsx b/modules/expo-selectable-text/src/ExpoUITextView.tsx index 2201fffcd2..017b4bd374 100644 --- a/modules/expo-selectable-text/src/ExpoUITextView.tsx +++ b/modules/expo-selectable-text/src/ExpoUITextView.tsx @@ -1,17 +1,17 @@ import React from 'react' import {requireNativeViewManager} from 'expo-modules-core' -import {StyleSheet, ViewStyle} from 'react-native' +import {StyleSheet, TextProps, ViewStyle} from 'react-native' import { ExpoUITextViewChildNativeProps, ExpoUITextViewNativeProps, ExpoUITextViewProps, } from './ExpoUITextView.types' -const NativeView: React.ComponentType = - requireNativeViewManager('ExpoSelectableText') +const ExpoUITextViewRoot: React.ComponentType = + requireNativeViewManager('ExpoUITextView') -const NativeViewChild: React.ComponentType = - requireNativeViewManager('ExpoTextChild') +const ExpoUITextViewChild: React.ComponentType = + requireNativeViewManager('ExpoUITextViewChild') const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([ false, @@ -19,6 +19,12 @@ const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([ ]) const useTextAncestorContext = () => React.useContext(TextAncestorContext) +const textDefaults: TextProps = { + allowFontScaling: true, + selectable: true, + lineBreakMode: 'tail', +} + export default function ExpoUITextView({ style, children, @@ -36,13 +42,16 @@ export default function ExpoUITextView({ if (!isAncestor) { return ( - + {React.Children.toArray(children).map((c, index) => { if (React.isValidElement(c)) { return c } else if (typeof c === 'string') { return ( - + ) } else { @@ -63,7 +72,7 @@ export default function ExpoUITextView({ return c } else if (typeof c === 'string') { return ( - {children} - + ) }