more implementations
This commit is contained in:
@@ -1,25 +1,9 @@
|
|||||||
# Expo Selectable Text
|
# Expo Selectable Text
|
||||||
|
|
||||||
This module creates a wrapper `<SelectableText>` for React Native's `<Text>` component. Text components are
|
This module is a drop-in replacement for React Native's `Text` component. Simply replace `<Text>` with `<UITextView>`.
|
||||||
parsed and rendered using UITextView instead of UILabel, allowing for text selection.
|
|
||||||
|
|
||||||
## Usage
|
## 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
|
Note that nested components should always use `<Text>` and not `<SelectableText>`. Only the outermost `<Text>` should
|
||||||
be replaced with `<SelectableText>`.
|
be replaced with `<SelectableText>`.
|
||||||
|
|
||||||
@@ -34,19 +18,54 @@ podspec inside of this directory.
|
|||||||
|
|
||||||
## Technical
|
## Technical
|
||||||
|
|
||||||
Whenever we use the SelectableText component, we then loop over each of the children (or just the text if the child
|
React Native's `Text` component allows for "infinite" nesting of further `Text` components. To make a true "drop-in",
|
||||||
is typeof Text). We create "segments" of text based on this.
|
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,
|
To achieve this, we first need to handle determining if we are dealing with an ancestor or root `UITextView` component.
|
||||||
we can add the styles for each segment individually (falling back to the root styles).
|
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
|
We create a context that contains a boolean to tell us if we have already rendered the root `UITextView`. We also store
|
||||||
NSAttributedString and handle their presses. However, this has a few downsides:
|
the root styles so that we can apply those styles if the ancestor `UITextView`s have not overwritten those styles.
|
||||||
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.
|
|
||||||
|
|
||||||
As such, we create a gesture recognizer for the entire UITextView. The recognizer determines the text at the position of
|
All of our children are placed into `ExpoUITextViewRoot`, which is the main native view that will display the native
|
||||||
the press, determines which segment it is, and sends an `onTextPress` event to the JS side along with the index of the
|
`UITextView`. There are no styles that need to be applied to this view, as we will be updating the size of the view
|
||||||
segment. On the JS side, we have our array of segments (that included the `onPress` event from the `Text`/`SelectableText`)
|
dynamically based on the text size.
|
||||||
that we can now call based on the index from the native event.
|
|
||||||
|
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`
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ExpoSelectableTextView from './src/ExpoUITextView'
|
import ExpoUITextView from './src/ExpoUITextView'
|
||||||
import {ExpoProTextViewProps} from './src/ExpoUITextView.types'
|
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?
|
var tapGestureRecognizer: UITapGestureRecognizer?
|
||||||
|
|
||||||
|
let onTextLayout = EventDispatcher()
|
||||||
|
|
||||||
|
// Props
|
||||||
|
|
||||||
public required init(appContext: AppContext? = nil) {
|
public required init(appContext: AppContext? = nil) {
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
textView = UITextView(usingTextLayoutManager: false)
|
textView = UITextView(usingTextLayoutManager: false)
|
||||||
@@ -18,8 +22,6 @@ class ExpoUITextView: ExpoView {
|
|||||||
textView.isEditable = false
|
textView.isEditable = false
|
||||||
textView.isScrollEnabled = false
|
textView.isScrollEnabled = false
|
||||||
textView.backgroundColor = .clear
|
textView.backgroundColor = .clear
|
||||||
textView.textContainer.lineBreakMode = .byTruncatingTail
|
|
||||||
textView.isSelectable = true
|
|
||||||
|
|
||||||
// Remove all of the padding from the view
|
// Remove all of the padding from the view
|
||||||
textView.textContainerInset = UIEdgeInsets.zero
|
textView.textContainerInset = UIEdgeInsets.zero
|
||||||
@@ -34,6 +36,14 @@ class ExpoUITextView: ExpoView {
|
|||||||
tapGestureRecognizer.isEnabled = true
|
tapGestureRecognizer.isEnabled = true
|
||||||
self.tapGestureRecognizer = tapGestureRecognizer
|
self.tapGestureRecognizer = tapGestureRecognizer
|
||||||
textView.addGestureRecognizer(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
|
// Update children whenever new react subviews are added
|
||||||
@@ -66,6 +76,23 @@ class ExpoUITextView: ExpoView {
|
|||||||
textView.frame.size = size
|
textView.frame.size = size
|
||||||
self.appContext?.reactBridge?.uiManager.setSize(size, for: self)
|
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 {
|
@IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void {
|
||||||
@@ -139,10 +166,14 @@ class ExpoUITextView: ExpoView {
|
|||||||
return
|
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
|
// Set some generic attributes that don't need ranges
|
||||||
let attributes: [NSAttributedString.Key:Any] = [
|
let attributes: [NSAttributedString.Key:Any] = [
|
||||||
.font: UIFont.systemFont(
|
.font: UIFont.systemFont(
|
||||||
ofSize: child.style?.fontSize ?? 12.0,
|
ofSize: scaledFontSize,
|
||||||
weight: child.style?.fontWeight?.toFontWeight() ?? .regular
|
weight: child.style?.fontWeight?.toFontWeight() ?? .regular
|
||||||
),
|
),
|
||||||
.foregroundColor: TextUtil.hexToUIColor(hex: child.style?.color),
|
.foregroundColor: TextUtil.hexToUIColor(hex: child.style?.color),
|
||||||
|
|||||||
@@ -2,9 +2,47 @@ import ExpoModulesCore
|
|||||||
|
|
||||||
public class ExpoUITextViewModule: Module {
|
public class ExpoUITextViewModule: Module {
|
||||||
public func definition() -> ModuleDefinition {
|
public func definition() -> ModuleDefinition {
|
||||||
Name("ExpoSelectableText")
|
Name("ExpoUITextView")
|
||||||
|
|
||||||
View(ExpoUITextView.self) {
|
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 class ExpoUITextViewChildModule: Module {
|
||||||
public func definition() -> ModuleDefinition {
|
public func definition() -> ModuleDefinition {
|
||||||
Name("ExpoTextChild")
|
Name("ExpoUITextViewChild")
|
||||||
|
|
||||||
View(ExpoUITextViewChild.self) {
|
View(ExpoUITextViewChild.self) {
|
||||||
Events("onTextPress")
|
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 React from 'react'
|
||||||
import {requireNativeViewManager} from 'expo-modules-core'
|
import {requireNativeViewManager} from 'expo-modules-core'
|
||||||
import {StyleSheet, ViewStyle} from 'react-native'
|
import {StyleSheet, TextProps, ViewStyle} from 'react-native'
|
||||||
import {
|
import {
|
||||||
ExpoUITextViewChildNativeProps,
|
ExpoUITextViewChildNativeProps,
|
||||||
ExpoUITextViewNativeProps,
|
ExpoUITextViewNativeProps,
|
||||||
ExpoUITextViewProps,
|
ExpoUITextViewProps,
|
||||||
} from './ExpoUITextView.types'
|
} from './ExpoUITextView.types'
|
||||||
|
|
||||||
const NativeView: React.ComponentType<ExpoUITextViewNativeProps> =
|
const ExpoUITextViewRoot: React.ComponentType<ExpoUITextViewNativeProps> =
|
||||||
requireNativeViewManager('ExpoSelectableText')
|
requireNativeViewManager('ExpoUITextView')
|
||||||
|
|
||||||
const NativeViewChild: React.ComponentType<ExpoUITextViewChildNativeProps> =
|
const ExpoUITextViewChild: React.ComponentType<ExpoUITextViewChildNativeProps> =
|
||||||
requireNativeViewManager('ExpoTextChild')
|
requireNativeViewManager('ExpoUITextViewChild')
|
||||||
|
|
||||||
const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([
|
const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([
|
||||||
false,
|
false,
|
||||||
@@ -19,6 +19,12 @@ const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([
|
|||||||
])
|
])
|
||||||
const useTextAncestorContext = () => React.useContext(TextAncestorContext)
|
const useTextAncestorContext = () => React.useContext(TextAncestorContext)
|
||||||
|
|
||||||
|
const textDefaults: TextProps = {
|
||||||
|
allowFontScaling: true,
|
||||||
|
selectable: true,
|
||||||
|
lineBreakMode: 'tail',
|
||||||
|
}
|
||||||
|
|
||||||
export default function ExpoUITextView({
|
export default function ExpoUITextView({
|
||||||
style,
|
style,
|
||||||
children,
|
children,
|
||||||
@@ -36,13 +42,16 @@ export default function ExpoUITextView({
|
|||||||
if (!isAncestor) {
|
if (!isAncestor) {
|
||||||
return (
|
return (
|
||||||
<TextAncestorContext.Provider value={[true, flattenedStyle]}>
|
<TextAncestorContext.Provider value={[true, flattenedStyle]}>
|
||||||
<NativeView style={{flex: 1}}>
|
<ExpoUITextViewRoot
|
||||||
|
{...textDefaults}
|
||||||
|
{...rest}
|
||||||
|
style={[{flex: 1}, rootStyle]}>
|
||||||
{React.Children.toArray(children).map((c, index) => {
|
{React.Children.toArray(children).map((c, index) => {
|
||||||
if (React.isValidElement(c)) {
|
if (React.isValidElement(c)) {
|
||||||
return c
|
return c
|
||||||
} else if (typeof c === 'string') {
|
} else if (typeof c === 'string') {
|
||||||
return (
|
return (
|
||||||
<NativeViewChild
|
<ExpoUITextViewChild
|
||||||
key={index}
|
key={index}
|
||||||
textStyle={flattenedStyle}
|
textStyle={flattenedStyle}
|
||||||
text={c}
|
text={c}
|
||||||
@@ -52,7 +61,7 @@ export default function ExpoUITextView({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</NativeView>
|
</ExpoUITextViewRoot>
|
||||||
</TextAncestorContext.Provider>
|
</TextAncestorContext.Provider>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -63,7 +72,7 @@ export default function ExpoUITextView({
|
|||||||
return c
|
return c
|
||||||
} else if (typeof c === 'string') {
|
} else if (typeof c === 'string') {
|
||||||
return (
|
return (
|
||||||
<NativeViewChild
|
<ExpoUITextViewChild
|
||||||
key={index}
|
key={index}
|
||||||
textStyle={flattenedStyle}
|
textStyle={flattenedStyle}
|
||||||
text={c}
|
text={c}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export interface ExpoUITextViewProps extends TextProps {}
|
|||||||
|
|
||||||
export interface ExpoUITextViewNativeProps {
|
export interface ExpoUITextViewNativeProps {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
style: ViewStyle
|
style: ViewStyle[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExpoUITextViewChildNativeProps extends ExpoUITextViewProps {
|
export interface ExpoUITextViewChildNativeProps extends ExpoUITextViewProps {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {Text as RNText, TextProps} from 'react-native'
|
|||||||
import {s, lh} from 'lib/styles'
|
import {s, lh} from 'lib/styles'
|
||||||
import {useTheme, TypographyVariant} from 'lib/ThemeContext'
|
import {useTheme, TypographyVariant} from 'lib/ThemeContext'
|
||||||
import {isIOS} from 'platform/detection'
|
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 & {
|
export type CustomTextProps = TextProps & {
|
||||||
type?: TypographyVariant
|
type?: TypographyVariant
|
||||||
@@ -29,11 +29,11 @@ export function Text({
|
|||||||
|
|
||||||
if (selectable && isIOS) {
|
if (selectable && isIOS) {
|
||||||
return (
|
return (
|
||||||
<ExpoSelectableTextView
|
<UITextView
|
||||||
style={[s.black, typography, lineHeightStyle, style]}
|
style={[s.black, typography, lineHeightStyle, style]}
|
||||||
{...props}>
|
{...props}>
|
||||||
{children}
|
{children}
|
||||||
</ExpoSelectableTextView>
|
</UITextView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user