more implementations

This commit is contained in:
Hailey
2024-01-15 04:57:35 -08:00
parent aa96bba066
commit 9f5b7602c1
9 changed files with 197 additions and 66 deletions
+49 -30
View File
@@ -1,25 +1,9 @@
# Expo Selectable Text
This module creates a wrapper `<SelectableText>` for React Native's `<Text>` 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 `<Text>` with `<UITextView>`.
## Usage
In most cases, you can simply use like so:
```tsx
<SelectableText selectable style={{color: '#000000', fontSize: 20}}>
Here is some text.
</SelectableText>
```
You may also have nested `<Text>` components inside the `<SelectableText>` block. For example:
```tsx
<SelectableText selectable style={{color: '#000000', fontSize: 20}}>
Here is some text. <Text style={{color: 'lightblue'}} onPress={() => {Alert.alert('Press!')}}>And this is a link.</Text>
</SelectableText>
```
Note that nested components should always use `<Text>` and not `<SelectableText>`. Only the outermost `<Text>` should
be replaced with `<SelectableText>`.
@@ -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`
+3 -3
View File
@@ -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}
@@ -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),
@@ -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")
@@ -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
}
}
}
@@ -1,15 +0,0 @@
import ExpoModulesCore
struct TextSegments: Record {
@Field
var segments: Array<TextSegment>
}
struct TextSegment: Record {
@Field
var index: Int
@Field
var text: String
@Field
var style: TextStyle?
}
@@ -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<ExpoUITextViewNativeProps> =
requireNativeViewManager('ExpoSelectableText')
const ExpoUITextViewRoot: React.ComponentType<ExpoUITextViewNativeProps> =
requireNativeViewManager('ExpoUITextView')
const NativeViewChild: React.ComponentType<ExpoUITextViewChildNativeProps> =
requireNativeViewManager('ExpoTextChild')
const ExpoUITextViewChild: React.ComponentType<ExpoUITextViewChildNativeProps> =
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 (
<TextAncestorContext.Provider value={[true, flattenedStyle]}>
<NativeView style={{flex: 1}}>
<ExpoUITextViewRoot
{...textDefaults}
{...rest}
style={[{flex: 1}, rootStyle]}>
{React.Children.toArray(children).map((c, index) => {
if (React.isValidElement(c)) {
return c
} else if (typeof c === 'string') {
return (
<NativeViewChild
<ExpoUITextViewChild
key={index}
textStyle={flattenedStyle}
text={c}
@@ -52,7 +61,7 @@ export default function ExpoUITextView({
)
}
})}
</NativeView>
</ExpoUITextViewRoot>
</TextAncestorContext.Provider>
)
} else {
@@ -63,7 +72,7 @@ export default function ExpoUITextView({
return c
} else if (typeof c === 'string') {
return (
<NativeViewChild
<ExpoUITextViewChild
key={index}
textStyle={flattenedStyle}
text={c}
@@ -5,7 +5,7 @@ export interface ExpoUITextViewProps extends TextProps {}
export interface ExpoUITextViewNativeProps {
children: React.ReactNode
style: ViewStyle
style: ViewStyle[]
}
export interface ExpoUITextViewChildNativeProps extends ExpoUITextViewProps {
+3 -3
View File
@@ -3,7 +3,7 @@ import {Text as RNText, TextProps} from 'react-native'
import {s, lh} from 'lib/styles'
import {useTheme, TypographyVariant} from 'lib/ThemeContext'
import {isIOS} from 'platform/detection'
import ExpoSelectableTextView from '../../../../../modules/expo-selectable-text/src/ExpoUITextView'
import {UITextView} from '../../../../../modules/expo-selectable-text'
export type CustomTextProps = TextProps & {
type?: TypographyVariant
@@ -29,11 +29,11 @@ export function Text({
if (selectable && isIOS) {
return (
<ExpoSelectableTextView
<UITextView
style={[s.black, typography, lineHeightStyle, style]}
{...props}>
{children}
</ExpoSelectableTextView>
</UITextView>
)
}