TRULY Selectable Text with React Native on iOS
Poorly written by hailey on April 15, 2024Disclaimer:
Since this is getting some traction, I just want to leave a note that I often write blog posts or journals after an implementation to sort through what the hell I just wrote. I iterate after that, and usually update the post. In this case, I've been pretty busy and have not had a chance to update this with some of the changes I've made since.
This is now available as a library! https://github.com/bluesky-social/react-native-uitextview. Please review the Limitations list, as there are a few.
Recently for the first time I realized that the <Text>
component in React Native is not selectable on iOS. What? But there's a selectable
prop on <Text>
what are you talking about? Well, turns out that it is true. First, it's important to understand the different types of text elements that UIKit provides.
<Text>
uses iOS's UILabel
. This makes sense since in most cases we don't actually need to have selection for our text. Also, since we can easily create scroll views, we don't need the scroll capability of UITextView (UITextView inherits from UIScrollView, but we will just disable that).
Unfortunately, UILabel
does not support highlight selection. If we set selectable
to true, the only new interaction we can have is a press-and-hold for a "Copy" option, which just lets us copy the entirety of the text.
<TextInput>
and setting editable
to false. Let's see what happens now:
It works! The reason for this is that <TextInput>
uses UITextView
whenever we set the multiline
prop to true
. We can apply whatever styles we want to individual <Text>
items and place all of that inside of our <TextInput>
. Wonderful, looks like we are finished here! Except...what happens if we need press events on this text?
Looks like this should work right? Unfortunately not. Although the styles are properly set on the pressable text, the press gestures are not actually registered. I'm not certain, since I have not looked underneath at this, but I would assume that since React Native assumes the text inside of <TextInput>
is going to be editable, they are not bothering to apply the press events to the individual NSAttributedString
s inside of the view (there could also be some other gesture recognizers they are using on the <TextInput>
that are blocking them, I don't know). Looks like we are back at square one.
Fortunately, creating our own native code in React Native is Easy™.
Can we use Expo Modules?
First of all, if you really just want to plug this into your own app, you can grab the code from the Bluesky app repo on GitHub. It's fairly well featured and should work mostly in line with RN's <Text>
. Just replace <Text>
with <UITextView>
(and all nested children as well) and boom, you're golden. However, if you're moreso just curious about how to do something like this on your own (whether for the same integration or something similar), read on.
It would be nice if we could use Expo Modules for this - it's a much cleaner API when writing native modules with Swift and Kotlin - however, as of right now this won't be possible.
Because we don't know the dimensions of the text we are passing to the view, we are going to need to perform the layouts on the native side before mounting. This will require use of React Native's shadow view. Unfortunately, we are not able to interface with the shadow view using Expo Modules yet.
I'm going to assume you have some experience building a native module, so I won't go into how to get that setup. I also won't be giving much detail about the shadow view, for two reasons: 1. it really needs a blog post of its own and 2. it's undocumented and, frankly, I am certain that I do not know enough about it to give you an explanation. If you are really curious, looking at React Native's text component as well as Facebook's Yoga documentation is helpful.
How does <Text> work?
You might be thinking this is going to be as easy as passing a value
prop to our new native module, rendering the UITextView
, and setting it's text
value to value
. We could probably just pass in a little style object (or even just use default styles) and set those as well, but it isn't really that hard. And you'd be right if all you care about is creating a quick little string. But of course, if that's what you wanted to do, you could just wrap your <Text>
inside of <TextInput>
like we've already seen. Nope, not going to work here.
It's useful to take a quick peek at how React Native handles nested Text components already (you can see the code here in their repo). There are a few basic steps:
- Create a context to keep track of whether the current
<Text>
that's being rendered is an ancestor or not. - Wrap the first
<Text>
inside of this context. - Render
<NativeText>
for the root<Text>
component. - Render
<NativeVirtualText>
for each ancestor, rendering it inside of the<NativeText>
component.
There's some more that goes on here, mainly to do with setting up event callbacks for presses (which we are going to handle slightly differently here), but that is the gist of it.
Setting up the logic
We are going to want to copy this logic. That means we need to create two new modules, which we will just call ExpoUITextView
and ExpoUITextViewChild
.
On the native side, we need to get our props over.
#import <React/RCTViewManager.h>
@interface RCT_EXTERN_MODULE(RNUITextViewManager, RCTViewManager)
RCT_REMAP_SHADOW_PROPERTY(numberOfLines, numberOfLines, NSInteger)
RCT_REMAP_SHADOW_PROPERTY(allowsFontScaling, allowsFontScaling, BOOL)
RCT_EXPORT_VIEW_PROPERTY(onTextLayout, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(ellipsizeMode, NSString)
RCT_EXPORT_VIEW_PROPERTY(selectable, BOOL)
@end
@interface RCT_EXTERN_MODULE(RNUITextViewChildManager, RCTViewManager)
RCT_REMAP_SHADOW_PROPERTY(text, text, NSString)
RCT_REMAP_SHADOW_PROPERTY(color, color, UIColor)
RCT_REMAP_SHADOW_PROPERTY(fontSize, fontSize, CGFloat)
RCT_REMAP_SHADOW_PROPERTY(fontStyle, fontStyle, NSString)
RCT_REMAP_SHADOW_PROPERTY(fontWeight, fontWeight, NSString)
RCT_REMAP_SHADOW_PROPERTY(letterSpacing, letterSpacing, CGFloat)
RCT_REMAP_SHADOW_PROPERTY(lineHeight, lineHeight, CGFloat)
RCT_REMAP_SHADOW_PROPERTY(pointerEvents, pointerEvents, NSString)
RCT_EXPORT_VIEW_PROPERTY(text, NSString)
RCT_EXPORT_VIEW_PROPERTY(onPress, RCTBubblingEventBlock)
@end
And on the JS side:
import {
requireNativeComponent,
UIManager,
Platform,
type ViewStyle,
TextProps,
} from 'react-native'
const LINKING_ERROR =
`The package 'react-native-ui-text-view' doesn't seem to be linked. Make sure: \n\n` +
Platform.select({ios: "- You have run 'pod install'\n", default: ''}) +
'- You rebuilt the app after installing the package\n' +
'- You are not using Expo Go\n'
export interface RNUITextViewProps extends TextProps {
children: React.ReactNode
style: ViewStyle[]
}
export interface RNUITextViewChildProps extends TextProps {
text: string
onTextPress?: (...args: any[]) => void
onTextLongPress?: (...args: any[]) => void
}
export const RNUITextView =
UIManager.getViewManagerConfig('RNUITextView') != null
? requireNativeComponent<RNUITextViewProps>('RNUITextView')
: () => {
throw new Error(LINKING_ERROR)
}
export const RNUITextViewChild =
UIManager.getViewManagerConfig('RNUITextViewChild') != null
? requireNativeComponent<RNUITextViewChildProps>('RNUITextViewChild')
: () => {
throw new Error(LINKING_ERROR)
}
export * from './UITextView'
Let's also create the JS component that will replace <Text>
. This is going to be pretty similar to React Native's existing code.
import React from 'react'
import {Platform, StyleSheet, TextProps, ViewStyle} from 'react-native'
import {RNUITextView, RNUITextViewChild} from './index'
const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([
false,
StyleSheet.create({}),
])
const useTextAncestorContext = () => React.useContext(TextAncestorContext)
const textDefaults: TextProps = {
allowFontScaling: true,
selectable: true,
}
export function UITextView({style, children, ...rest}: TextProps) {
const [isAncestor, rootStyle] = useTextAncestorContext()
// Flatten the styles, and apply the root styles when needed
const flattenedStyle = React.useMemo(
() => StyleSheet.flatten([rootStyle, style]),
[rootStyle, style],
)
if (Platform.OS !== 'ios') {
throw new Error('UITextView is only available on iOS')
}
if (!isAncestor) {
return (
<TextAncestorContext.Provider value={[true, flattenedStyle]}>
<RNUITextView
{...textDefaults}
{...rest}
ellipsizeMode={rest.ellipsizeMode ?? rest.lineBreakMode ?? 'tail'}
style={[{flex: 1}, flattenedStyle]}
onPress={undefined} // We want these to go to the children only
onLongPress={undefined}>
{React.Children.toArray(children).map((c, index) => {
if (React.isValidElement(c)) {
return c
} else if (typeof c === 'string') {
return (
<RNUITextViewChild
key={index}
style={flattenedStyle}
text={c}
{...rest}
/>
)
}
})}
</RNUITextView>
</TextAncestorContext.Provider>
)
} else {
return (
<>
{React.Children.toArray(children).map((c, index) => {
if (React.isValidElement(c)) {
return c
} else if (typeof c === 'string') {
return (
<RNUITextViewChild
key={index}
style={flattenedStyle}
text={c}
{...rest}
/>
)
}
})}
</>
)
}
}
Let's note a few things here:
- Any time we encounter an element who's child is a type of
string
, we need to render aRNUITextViewChild
. React Native doesn't let us render plain text outside of a<Text>
component, and while this might be something that we could remedy, it isn't worth the extra headache. Instead, we can just use atext
prop on the view. - The root view receives the styles as well. The root view is actually just a regular
RCTView
that uses flex box. Since we might apply things likepadding
ormargin
to the text, we want that to be reflected on the container.
Every child, recursively, gets rendered until we either encounter plain text in the child...or we don't. We cannot simply look through the children in React.Children.toArray()
and create a new array of elements, since if we did that we wouldn't be rendering everything. Imagine:
<UITextView>
Here's some text <Text style={{color: 'red'}}>and here's some red text <Text style={{lineDecoration: 'underline'}}>that is eventually underlined</Text></Text>
</UITextView>
If we don't actually render these additional <Text>
components, we won't know what their children are, and we won't ever get any of the text outside of Here's some text
. Don't worry, these won't actually be displayed.
That's really all we need on the JS side, for now. Let's take a look at the native side.
Setup the view manager
Because we need to have two separate native views, we are going to create two view managers for this module. Both of them will be identical, except for the root view, we will need to pass the RCTBridge
to the shadow view.
@objc(RNUITextViewManager)
class RNUITextViewManager: RCTViewManager {
override func view() -> (RNUITextView) {
return RNUITextView()
}
@objc override static func requiresMainQueueSetup() -> Bool {
return true
}
override func shadowView() -> RCTShadowView {
// Pass the bridge to the shadow view
return RNUITextViewShadow(bridge: self.bridge)
}
}
@objc(RNUITextViewChildManager)
class RNUITextViewChildManager: RCTViewManager {
override func view() -> (RNUITextViewChild) {
return RNUITextViewChild()
}
@objc override static func requiresMainQueueSetup() -> Bool {
return true
}
override func shadowView() -> RCTShadowView {
return RNUITextViewChildShadow()
}
}
Create the root shadow view
First we need to create a new class, RNUITextViewShadow
which is a subclass of RCTShadowView
. We also need to create the init()
that receives the bridge saves it. Finally, there are a few functions that we need to override.
class RNUITextView: RCTShadowView {
@objc var numberOfLines: Int = 0 {
didSet {
// We will use this later
}
}
@objc var allowsFontScaling: Bool = true
// For storing our created string
var attributedText: NSAttributedString = NSAttributedString()
// For storing the frame size when we first calculate it
var frameSize: CGSize = CGSize()
// For storing the line height when we create the styles
var lineHeight: CGFloat = 0
init(bridge: RCTBridge) {
self.bridge = bridge
super.init()
}
// Tell react to not use flexbox for this view
override func isYogaLeafNode() -> Bool {
return true
}
override func insertReactSubview(_ subview: RCTShadowView!, at atIndex: Int) {
// We only want to insert shadow view children
if subview.isKind(of: RNUITextViewChildShadow.self) {
super.insertReactSubview(subview, at: atIndex)
}
}
// Update the text when subviews change
override func didUpdateReactSubviews() {
self.setAttributedText()
}
override func layoutSubviews(with layoutContext: RCTLayoutContext) {
// We will use this later
}
override func dirtyLayout() {
super.dirtyLayout()
// This will tell React to remeasure the view
YGNodeMarkDirty(self.yogaNode)
}
func setAttributedText() -> Void {
// We will style the text here
}
func getNeededSize(maxWidth: Float) -> YGSize {
// Here we will determine the required container size for the styled text
}
}
Let's briefly review what is going to happen here:
- We tell React to not use flexbox for layout, but instead to use a custom function (which we will set up shortly) for measurements.
- Receive the necessary props from the root view.
- Manage subview insertions and overriding the default layout. We can't layout until the
YGNode
has been measured, which is what we useYGNodeIsDirty()
for. - Any time our subviews update, we recreate the attributed string and (shortly) tell React to re-measure the
YGNode
.
Let's start implementing that.
Creating our styled text
Styling the text is fairly simple. This is a super brief way that you can do so. Feel free to add whatever other styles you want!
func setAttributedText() -> Void {
// Create an attributed string to store each of the segments
let finalAttributedString = NSMutableAttributedString()
self.reactSubviews().forEach { child in
guard let child = child as? RNUITextViewChildShadow else {
return
}
let scaledFontSize = self.allowsFontScaling ?
UIFontMetrics.default.scaledValue(for: child.fontSize) : child.fontSize
let font = UIFont.systemFont(ofSize: scaledFontSize, weight: child.getFontWeight())
// Set some generic attributes that don't need ranges
let attributes: [NSAttributedString.Key:Any] = [
.font: font,
.foregroundColor: child.color,
]
// Create the attributed string with the generic attributes
let string = NSMutableAttributedString(string: child.text, attributes: attributes)
// Set the paragraph style attributes if necessary
let paragraphStyle = NSMutableParagraphStyle()
if child.lineHeight != 0.0 {
paragraphStyle.minimumLineHeight = child.lineHeight
paragraphStyle.maximumLineHeight = child.lineHeight
string.addAttribute(
NSAttributedString.Key.paragraphStyle,
value: paragraphStyle,
range: NSMakeRange(0, string.length)
)
// Store that height
self.lineHeight = child.lineHeight
} else {
self.lineHeight = font.lineHeight
}
finalAttributedString.append(string)
}
self.attributedText = finalAttributedString
self.dirtyLayout()
}
A few things to note here:
First, notice that we are saving the line height for later. This is because we need to know what our line height is set to for measuring the text container size. If a line height isn't supplied in the React view's props, then we need to use the default line height for the font we are using.
Second, notice that we are calling dirtyLayout()
as soon as we have changed our text. This is going to tell React that it's time to re-measure the view.
Let's also setup the logic to determine the size of the container.
func getNeededSize(maxWidth: Float) -> YGSize {
// Create the max size and figure out the size of the entire text
let maxSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(MAXFLOAT))
let textSize = self.attributedText.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, context: nil)
// Figure out how many total lines there are
let totalLines = Int(ceil(textSize.height / self.lineHeight))
// Default to the text size
var neededSize: CGSize = textSize.size
// If the total lines > max number, return size with the max
if self.numberOfLines != 0, totalLines > self.numberOfLines {
neededSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(CGFloat(self.numberOfLines) * self.lineHeight))
}
self.frameSize = neededSize
return YGSize(width: Float(neededSize.width), height: Float(neededSize.height))
}
This is pretty straight forward as well. If you're used to just being able to use sizeToFit()
on a UILabel
or UITextView
, we unfortunately can't do that in this case. We need to determine these dimensions before setting the text, otherwise React won't know the proper size until after the text appears - creating a flicker.
Setting the measure function and laying out
Inside of our initializer, we are going to set the measure function for our YGNode
.
init(bridge: RCTBridge) {
self.bridge = bridge
super.init()
// We need to set a custom measure func here to calculate the height correctly
YGNodeSetMeasureFunc(self.yogaNode) { node, width, widthMode, height, heightMode in
// Get the shadowview and determine the needed size to set
let shadowView = Unmanaged<RNUITextViewShadow>.fromOpaque(YGNodeGetContext(node)).takeUnretainedValue()
return shadowView.getNeededSize(maxWidth: width)
}
}
Now, when React renders this component, it will automatically be set to the appropriate size for our text!
Whenever it's time to layout the views, we need to actually update the UITextView
. This view, however, doesn't live inside of the shadow view, but rather inside of the UIView
for this component. Therefore, we need to get that view and update it's text. Here's how we can do that.
override func layoutSubviews(with layoutContext: RCTLayoutContext) {
// Don't do anything if the layout is dirty
if(YGNodeIsDirty(self.yogaNode)) {
return
}
// Update the text
self.bridge.uiManager.addUIBlock { uiManager, viewRegistry in
// Try to get the view
guard let textView = viewRegistry?[self.reactTag] as? RNUITextView else {
return
}
// Set the text, along with some other properties. We will set this view up now.
textView.setText(string: self.attributedText, size: self.frameSize, numberOfLines: self.numberOfLines)
}
}
The child shadow view
Before we continue, we also need to create that child shadow view we are using above. This is a very simple one, and is only for receiving the props.
// We want all of our props to be available in the child's shadow view so we
// can create the attributed text before mount and calculate the needed size
// for the view.
class RNUITextViewChildShadow: RCTShadowView {
@objc var text: String = ""
@objc var color: UIColor = .black
@objc var fontSize: CGFloat = 16.0
@objc var fontStyle: String = "normal"
@objc var fontWeight: String = "normal"
@objc var letterSpacing: CGFloat = 0.0
@objc var lineHeight: CGFloat = 0.0
@objc var pointerEvents: NSString?
override func isYogaLeafNode() -> Bool {
return true
}
override func didSetProps(_ changedProps: [String]!) {
guard let superview = self.superview as? RNUITextViewShadow else {
return
}
if !YGNodeIsDirty(superview.yogaNode) {
superview.setAttributedText()
}
}
func getFontWeight() -> UIFont.Weight {
switch self.fontWeight {
case "bold":
return .bold
case "normal":
return .regular
case "100":
return .ultraLight
case "200":
return .ultraLight
case "300":
return .light
case "400":
return .regular
case "500":
return .medium
case "600":
return .semibold
case "700":
return .semibold
case "800":
return .bold
case "900":
return .heavy
default:
return .regular
}
}
}
Also, go ahead and create the RNUITextViewChild
class.
class RNUITextViewChild: UIView {
@objc var text: String?
@objc var onPress: RCTDirectEventBlock?
}
The main view
The main view is a subclass of UIView. We will override a few of the views functions, and create the UITextView
in the init.
class RNUITextView: UIView {
var textView: UITextView
// Props
@objc var numberOfLines: Int = 0 {
didSet {
textView.textContainer.maximumNumberOfLines = numberOfLines
}
}
@objc var selectable: Bool = true {
didSet {
textView.isSelectable = selectable
}
}
@objc var ellipsizeMode: String = "tail" {
didSet {
textView.textContainer.lineBreakMode = self.getLineBreakMode()
}
}
@objc var onTextLayout: RCTDirectEventBlock?
// Init
override init(frame: CGRect) {
// Use the appropriate TextKit version
if #available(iOS 16.0, *) {
textView = UITextView(usingTextLayoutManager: false)
} else {
textView = UITextView()
}
// Disable scrolling
textView.isScrollEnabled = false
// Remove all the padding
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
// Remove other properties
textView.isEditable = false
textView.backgroundColor = .clear
// Init
super.init(frame: frame)
self.clipsToBounds = true
// Add the view
addSubview(textView)
// Add gestures for onPress
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
tapGestureRecognizer.isEnabled = true
textView.addGestureRecognizer(tapGestureRecognizer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// Resolves some animation issues
override func reactSetFrame(_ frame: CGRect) {
UIView.performWithoutAnimation {
super.reactSetFrame(frame)
}
}
// This is the function called from the shadow view.
func setText(string: NSAttributedString, size: CGSize, numberOfLines: Int) -> Void {
self.textView.frame.size = size
self.textView.textContainer.maximumNumberOfLines = numberOfLines
self.textView.attributedText = string
self.textView.selectedTextRange = nil
if let onTextLayout = self.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
])
}
}
@IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void {
// If we find a child, then call onPress
if let child = getPressed(sender) {
if textView.selectedTextRange == nil, let onPress = child.onPress {
onPress(["": ""])
} else {
// Clear the selected text range if we are not pressing on a link
textView.selectedTextRange = nil
}
}
}
// Try to get the pressed segment
func getPressed(_ sender: UITapGestureRecognizer) -> RNUITextViewChild? {
let layoutManager = textView.layoutManager
var location = sender.location(in: textView)
// Remove the padding
location.x -= textView.textContainerInset.left
location.y -= textView.textContainerInset.top
// Get the index of the char
let charIndex = layoutManager.characterIndex(
for: location,
in: textView.textContainer,
fractionOfDistanceBetweenInsertionPoints: nil
)
for child in self.reactSubviews() {
if let child = child as? RNUITextViewChild, let childText = child.text {
let fullText = self.textView.attributedText.string
let range = fullText.range(of: childText)
if let lowerBound = range?.lowerBound, let upperBound = range?.upperBound {
if charIndex >= lowerBound.utf16Offset(in: fullText) && charIndex <= upperBound.utf16Offset(in: fullText) {
return child
}
}
}
}
return nil
}
func getLineBreakMode() -> NSLineBreakMode {
switch self.ellipsizeMode {
case "head":
return .byTruncatingHead
case "middle":
return .byTruncatingMiddle
case "tail":
return .byTruncatingTail
case "clip":
return .byClipping
default:
return .byTruncatingTail
}
}
}
Going Further
With more implementations of things like onTextLayout
, you can bring this into near parity with the exsiting <Text>
component, and create a drop-in replacement for those times when you need to just do a bit more with text.
It's also possible to render Markdown with NSAttributedString! See https://developer.apple.com/documentation/foundation/nsattributedstring/3796598-init
Whatever you want to do, this should get you most of the way there!