Merge PR #2495 by haileyok
Squashed commit of the following: commit 9d9c46ced116079add8ae1beaed854b38962d608 Author: Paul Frazee <pfrazee@gmail.com> Date: Tue Jan 23 14:12:32 2024 -0800 Fix reference error on the web build commit 1981621c5b6f2b63b3e3875b68721161487d7df0 Merge:zio/stablecda4fe4a
0d9b6954 Author: Paul Frazee <pfrazee@gmail.com> Date: Tue Jan 23 12:43:51 2024 -0800 Merge branch 'feat/selectable-text' of https://github.com/haileyok/social-app into haileyok-feat/selectable-text commit 0d9b6954472bb89f63be479d79986bb6d8b7e735 Merge: 3c381f94f1a7a571
Author: Hailey <153161762+haileyok@users.noreply.github.com> Date: Fri Jan 19 16:42:13 2024 -0800 Merge branch 'main' into feat/selectable-text commit 3c381f94700167367b8519cb5d56360c51cea131 Merge: f9510156fb596e7f
Author: Hailey <153161762+haileyok@users.noreply.github.com> Date: Thu Jan 18 23:48:10 2024 -0800 Merge branch 'main' into feat/selectable-text commit f951015637132d99d3523c1d93279b6b0b728293 Author: Hailey <me@haileyok.com> Date: Thu Jan 18 23:46:25 2024 -0800 update readme commit aa9b8b06eda6c4a00f7e4b0bcd5f7e5205c9b166 Author: Hailey <me@haileyok.com> Date: Thu Jan 18 23:37:49 2024 -0800 calculate line height commit 9fe479630c763fe3fe5dd7b8a5a6d82803f1ad06 Author: Hailey <me@haileyok.com> Date: Thu Jan 18 23:19:31 2024 -0800 improve height calculation, render on prop changes commit 209caffa7df30af933eff10ab16bf32d53b26df4 Author: Hailey <me@haileyok.com> Date: Thu Jan 18 22:53:08 2024 -0800 presses commit 384c8ec3a8774b075d0dca665d01de82ff9d19bd Author: Hailey <me@haileyok.com> Date: Thu Jan 18 21:57:56 2024 -0800 line break mode commit adfcf05fe498b5ab6554e9b3fd399d7dd3ade79b Author: Hailey <me@haileyok.com> Date: Thu Jan 18 21:50:21 2024 -0800 onTextLayout event commit e9ba104e6f12eb8144ee752335cdeecdfbf3d8e5 Author: Hailey <me@haileyok.com> Date: Thu Jan 18 21:34:35 2024 -0800 better naming commit e335f5ab7f813ec0d458476eeb91d0070fde0933 Author: Hailey <me@haileyok.com> Date: Thu Jan 18 21:31:38 2024 -0800 remove android commit 9e197934ba996a422ab03a204255a1b0b40d2d25 Author: Hailey <me@haileyok.com> Date: Thu Jan 18 21:28:28 2024 -0800 remove expo module commit 99882c7e3976a0cb59648e67f0eb4916f93f6830 Author: Hailey <me@haileyok.com> Date: Thu Jan 18 21:27:43 2024 -0800 handle presses commit 18f818649efcd1e18c810aaf4ea1a4cb93ddd111 Author: Hailey <me@haileyok.com> Date: Thu Jan 18 21:14:38 2024 -0800 make use of rctshadowview commit 7134e1106e338013555c984607d51124727b9264 Author: Hailey <me@haileyok.com> Date: Wed Jan 17 20:38:39 2024 -0800 stop unnecessary layouts, resize container before setting text commit 340b84f053d48e45a5e4e9648ac4f87fc00e5f4a Author: Hailey <me@haileyok.com> Date: Wed Jan 17 11:17:36 2024 -0800 handle prop changes for both children and root views commit d906fe4fcfa4a919dbb66f4ec3f17e8f8be8bf02 Author: Hailey <me@haileyok.com> Date: Tue Jan 16 18:42:22 2024 -0800 handle onpress better commit b6b096416894893973be54793f4d3e3f08974293 Author: Hailey <me@haileyok.com> Date: Tue Jan 16 16:57:31 2024 -0800 resolve animation issue, animate alt text expansion commit daedd1f671fc933af27e2953b52b3a08eddb7c92 Author: Hailey <me@haileyok.com> Date: Tue Jan 16 15:47:24 2024 -0800 move getChildren to didMoveToWindow commit 87d44e4b576cce56a12a1f887e1b9605db1427aa Author: Hailey <me@haileyok.com> Date: Mon Jan 15 18:48:36 2024 -0800 simplify getPressed commit d92584bad7db7179d95f155bd480854df8fae17f Author: Hailey <me@haileyok.com> Date: Mon Jan 15 17:56:43 2024 -0800 just more cleanup commit d39f7a937dc8b47b98d120469db35d697bcf74be Author: Hailey <me@haileyok.com> Date: Mon Jan 15 17:03:19 2024 -0800 remove unnecessary property for gesture recognizer commit a35513a1d236bcd94aab0e7c5ac1cd0907f61762 Author: Hailey <me@haileyok.com> Date: Mon Jan 15 16:55:36 2024 -0800 remove debug line commit 788956aa01d2b46783ad0d0a45949fc5ca9e0aab Author: Hailey <me@haileyok.com> Date: Mon Jan 15 16:33:44 2024 -0800 typo commit a3ba6e782542a8e9ca09b5b49b1043ba046dcc70 Author: Hailey <me@haileyok.com> Date: Mon Jan 15 13:42:25 2024 -0800 make alt text selectable commit e5472a13da277ef7cccb870d62197dd86b9c3e86 Author: Hailey <me@haileyok.com> Date: Mon Jan 15 05:27:15 2024 -0800 re-render on numberOfLines change commit 9f5b7602c11a92cb83704feb3946fe6b4f584fa5 Author: Hailey <me@haileyok.com> Date: Mon Jan 15 04:57:35 2024 -0800 more implementations commit aa96bba0664d14f12ee742739c70847407062f35 Author: Hailey <me@haileyok.com> Date: Mon Jan 15 03:12:43 2024 -0800 merge main in what are you doing there? go away fix recognizer to clear selected text on tap remove jank/hacks update readme remove android stuff (?) don't remove clipped subview on android for selection enable selection of alt text add numberOfLines properly apply container styles handle both selection and expand press events in alt text far better implementation revert link changes revert lightbox changes for now fix file name commit ec8c05f3f05949b6e3ae8be2e4d153d7d51b18f9 Merge: 2435a25212a0ceee
Author: Hailey <me@haileyok.com> Date: Fri Jan 12 23:41:10 2024 -0800 Merge branch 'main' into feat/selectable-text # Conflicts: # src/view/com/util/Link.tsx commit 2435a25257c4a3b12c38949b1928848a0acf1a97 Author: Hailey <me@haileyok.com> Date: Fri Jan 12 23:30:13 2024 -0800 cleanup commit fdf75927f6fc176a390a11cba56e462c6fe48bdf Author: Hailey <me@haileyok.com> Date: Fri Jan 12 23:25:23 2024 -0800 remove debug commit 36d8cd82ef57483dcf3740c803c6524bc76e87c9 Author: Hailey <me@haileyok.com> Date: Fri Jan 12 23:25:17 2024 -0800 reset text selection on text update commit b8f7bc23c2df8532941af8b62a4d36a4814c5965 Author: Hailey <me@haileyok.com> Date: Fri Jan 12 23:24:43 2024 -0800 use textkit 1 commit 5216464458f4ffd1d6384a1d15ca7be5e8a96d5d Author: Hailey <me@haileyok.com> Date: Fri Jan 12 22:50:15 2024 -0800 properly handle link press events commit 2802902c69f5d68140c3b573115e8e73638ce9b5 Author: Hailey <me@haileyok.com> Date: Fri Jan 12 22:49:47 2024 -0800 modify Link so that we can create the TextLink press handler outside commit 860610e63ab15cfa9b18da317243137b35a6bf6d Author: Hailey <me@haileyok.com> Date: Fri Jan 12 19:17:51 2024 -0800 always make sure we use the latest styles commit 7f05d0141b6355aa4f521f91056edc06ffc2f5ba Author: Hailey <me@haileyok.com> Date: Fri Jan 12 16:57:08 2024 -0800 update readme with tech info commit b8318446a34d07fb0fc37029c3143d0b81eb2b29 Author: Hailey <me@haileyok.com> Date: Fri Jan 12 16:34:35 2024 -0800 remove all uitextview padding commit 0f0b6aa131a1e68e0e4eeb456157c866ebc85de3 Author: Hailey <me@haileyok.com> Date: Fri Jan 12 16:34:28 2024 -0800 cleanup imports commit c9f0064836d5fe26c55ce571b5d1abf5678ca3a5 Author: Hailey <me@haileyok.com> Date: Fri Jan 12 16:18:08 2024 -0800 update interface commit 7dcac644baeedb506f91f1f4dcaf80dbfb46f610 Author: Hailey <me@haileyok.com> Date: Fri Jan 12 16:13:49 2024 -0800 remove useless struct commit 5174744213c97cb74ca7fe3a513a3abc108fe83d Author: Hailey <me@haileyok.com> Date: Fri Jan 12 16:13:34 2024 -0800 adjust deps commit ce8b9ed62bcf484ad498b0de05998d8986b132ac Author: Hailey <me@haileyok.com> Date: Thu Jan 11 22:15:50 2024 -0800 add readme, update info commit 33c6e3b15c64bcb952b62d1f5c3100c517a64c57 Author: Hailey <me@haileyok.com> Date: Thu Jan 11 22:04:53 2024 -0800 remove unnecessary android/web stuff commit fbca531bdfeff90bd2a99214482e102f2601c453 Author: Hailey <me@haileyok.com> Date: Thu Jan 11 22:02:30 2024 -0800 simplify cast of string.index to int before i forget commit 648552eafbc3bf861567ca160c6e84295eec26f8 Author: Hailey <me@haileyok.com> Date: Thu Jan 11 02:01:20 2024 -0800 wip commit c6d2e54923e779180f456bef3ba275dcb2f74d5d Author: Hailey <me@haileyok.com> Date: Thu Jan 11 00:38:47 2024 -0800 selectable text experiment
parent
cda4fe4a7f
commit
a2b58852e7
|
@ -91,8 +91,8 @@ web-build/
|
|||
|
||||
|
||||
# Android & iOS folders
|
||||
android/
|
||||
ios/
|
||||
/android/
|
||||
/ios/
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
# React Native UITextView
|
||||
|
||||
Drop in replacement for `<Text>` that renders a `UITextView`, support selection and native translation features on iOS.
|
||||
|
||||
## Installation
|
||||
|
||||
In this project, no installation is required. The pod will be installed automatically during a `pod install`.
|
||||
|
||||
In another project, clone the repo and copy the `modules/react-native-ui-text-view` directory to your own project
|
||||
directory. Afterward, run `pod install`.
|
||||
|
||||
## Usage
|
||||
|
||||
Replace the outermost `<Text>` with `<UITextView>`. Styles and press events should be handled the same way they would
|
||||
with `<Text>`. Both `<UITextView>` and `<Text>` are supported as children of the root `<UITextView>`.
|
||||
|
||||
## Technical
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
All of our children are placed into `RNUITextView`, which is the main native view that will display the iOS `UITextView`.
|
||||
|
||||
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 `RNUITextViewChild`. We continue
|
||||
down the tree, until we run out of children.
|
||||
|
||||
On the native side, we make use of the shadow view to calculate text container dimensions before the views are mounted.
|
||||
We cannot simply set the `UITextView` text first, since React will not have properly measured the layout before this
|
||||
occurs.
|
||||
|
||||
|
||||
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`
|
|
@ -0,0 +1,3 @@
|
|||
#import <React/RCTViewManager.h>
|
||||
#import <React/RCTBridge.h>
|
||||
#import <React/RCTBridge+Private.h>
|
|
@ -0,0 +1,141 @@
|
|||
class RNUITextView: UIView {
|
||||
var textView: UITextView
|
||||
|
||||
@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?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
class RNUITextViewChild: UIView {
|
||||
@objc var text: String?
|
||||
@objc var onPress: RCTDirectEventBlock?
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
// 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
#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
|
|
@ -0,0 +1,30 @@
|
|||
@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()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,147 @@
|
|||
class RNUITextViewShadow: RCTShadowView {
|
||||
// Props
|
||||
@objc var numberOfLines: Int = 0 {
|
||||
didSet {
|
||||
if !YGNodeIsDirty(self.yogaNode) {
|
||||
self.setAttributedText()
|
||||
}
|
||||
}
|
||||
}
|
||||
@objc var allowsFontScaling: Bool = true
|
||||
|
||||
var attributedText: NSAttributedString = NSAttributedString()
|
||||
var frameSize: CGSize = CGSize()
|
||||
|
||||
var lineHeight: CGFloat = 0
|
||||
|
||||
var bridge: RCTBridge
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Subscribe to ynamic type size changes
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(preferredContentSizeChanged(_:)),
|
||||
name: UIContentSizeCategory.didChangeNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc func preferredContentSizeChanged(_ notification: Notification) {
|
||||
self.setAttributedText()
|
||||
}
|
||||
|
||||
// Tell yoga not to use flexbox
|
||||
override func isYogaLeafNode() -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// We only need to insert text children
|
||||
override func insertReactSubview(_ subview: RCTShadowView!, at atIndex: Int) {
|
||||
if subview.isKind(of: RNUITextViewChildShadow.self) {
|
||||
super.insertReactSubview(subview, at: atIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// Whenever the subvies update, set the text
|
||||
override func didUpdateReactSubviews() {
|
||||
self.setAttributedText()
|
||||
}
|
||||
|
||||
// Whenever we layout, update the UI
|
||||
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
|
||||
guard let textView = viewRegistry?[self.reactTag] as? RNUITextView else {
|
||||
return
|
||||
}
|
||||
textView.setText(string: self.attributedText, size: self.frameSize, numberOfLines: self.numberOfLines)
|
||||
}
|
||||
}
|
||||
|
||||
override func dirtyLayout() {
|
||||
super.dirtyLayout()
|
||||
YGNodeMarkDirty(self.yogaNode)
|
||||
}
|
||||
|
||||
// Update the attributed text whenever changes are made to the subviews.
|
||||
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()
|
||||
}
|
||||
|
||||
// Create a YGSize based on the max width
|
||||
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))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "react-native-ui-text-view",
|
||||
"version": "0.1.0",
|
||||
"description": "UITextView in React Native on iOS",
|
||||
"main": "src/index",
|
||||
"author": "haileyok",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/bluesky-social/social-app/modules/react-native-ui-text-view"
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
require "json"
|
||||
|
||||
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
||||
folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32'
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
s.name = "react-native-ui-text-view"
|
||||
s.version = package["version"]
|
||||
s.summary = package["description"]
|
||||
s.homepage = package["homepage"]
|
||||
s.license = package["license"]
|
||||
s.authors = package["author"]
|
||||
|
||||
s.platforms = { :ios => "11.0" }
|
||||
s.source = { :git => ".git", :tag => "#{s.version}" }
|
||||
|
||||
s.source_files = "ios/**/*.{h,m,mm,swift}"
|
||||
|
||||
# Use install_modules_dependencies helper to install the dependencies if React Native version >=0.71.0.
|
||||
# See https://github.com/facebook/react-native/blob/febf6b7f33fdb4904669f99d795eba4c0f95d7bf/scripts/cocoapods/new_architecture.rb#L79.
|
||||
if respond_to?(:install_modules_dependencies, true)
|
||||
install_modules_dependencies(s)
|
||||
else
|
||||
s.dependency "React-Core"
|
||||
|
||||
# Don't install the dependencies when we run `pod install` in the old architecture.
|
||||
if ENV['RCT_NEW_ARCH_ENABLED'] == '1' then
|
||||
s.compiler_flags = folly_compiler_flags + " -DRCT_NEW_ARCH_ENABLED=1"
|
||||
s.pod_target_xcconfig = {
|
||||
"HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\"",
|
||||
"OTHER_CPLUSPLUSFLAGS" => "-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1",
|
||||
"CLANG_CXX_LANGUAGE_STANDARD" => "c++17"
|
||||
}
|
||||
s.dependency "React-RCTFabric"
|
||||
s.dependency "React-Codegen"
|
||||
s.dependency "RCT-Folly"
|
||||
s.dependency "RCTRequired"
|
||||
s.dependency "RCTTypeSafety"
|
||||
s.dependency "ReactCommon/turbomodule/core"
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,76 @@
|
|||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
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 &&
|
||||
UIManager.getViewManagerConfig('RNUITextView') != null
|
||||
? requireNativeComponent<RNUITextViewProps>('RNUITextView')
|
||||
: () => {
|
||||
throw new Error(LINKING_ERROR)
|
||||
}
|
||||
|
||||
export const RNUITextViewChild =
|
||||
UIManager.getViewManagerConfig &&
|
||||
UIManager.getViewManagerConfig('RNUITextViewChild') != null
|
||||
? requireNativeComponent<RNUITextViewChildProps>('RNUITextViewChild')
|
||||
: () => {
|
||||
throw new Error(LINKING_ERROR)
|
||||
}
|
||||
|
||||
export * from './UITextView'
|
|
@ -175,7 +175,8 @@
|
|||
"tlds": "^1.234.0",
|
||||
"use-deep-compare": "^1.1.0",
|
||||
"zeego": "^1.6.2",
|
||||
"zod": "^3.20.2"
|
||||
"zod": "^3.20.2",
|
||||
"react-native-ui-text-view": "link:./modules/react-native-ui-text-view"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto/dev-env": "^0.2.19",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, View, Pressable} from 'react-native'
|
||||
import {LayoutAnimation, StyleSheet, View} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import ImageView from './ImageViewing'
|
||||
import {shareImageModal, saveImageToMediaLibrary} from 'lib/media/manip'
|
||||
|
@ -105,19 +105,21 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
|
|||
return (
|
||||
<View style={[styles.footer]}>
|
||||
{altText ? (
|
||||
<Pressable
|
||||
onPress={() => setAltExpanded(!isAltExpanded)}
|
||||
onLongPress={() => {}}
|
||||
accessibilityRole="button">
|
||||
<View>
|
||||
<Text
|
||||
selectable
|
||||
style={[s.gray3, styles.footerText]}
|
||||
numberOfLines={isAltExpanded ? undefined : 3}>
|
||||
{altText}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
<View accessibilityRole="button" style={styles.footerText}>
|
||||
<Text
|
||||
style={[s.gray3]}
|
||||
numberOfLines={isAltExpanded ? undefined : 3}
|
||||
selectable
|
||||
onPress={() => {
|
||||
LayoutAnimation.configureNext({
|
||||
duration: 300,
|
||||
update: {type: 'spring', springDamping: 0.7},
|
||||
})
|
||||
setAltExpanded(prev => !prev)
|
||||
}}>
|
||||
{altText}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.footerBtns}>
|
||||
<Button
|
||||
|
|
|
@ -40,7 +40,7 @@ import {
|
|||
usePreferencesQuery,
|
||||
} from '#/state/queries/preferences'
|
||||
import {useSession} from '#/state/session'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {isAndroid, isNative} from '#/platform/detection'
|
||||
import {logger} from '#/logger'
|
||||
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
|
||||
|
||||
|
@ -400,6 +400,7 @@ function PostThreadLoaded({
|
|||
style={s.hContentRegion}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
removeClippedSubviews={isAndroid ? false : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -248,10 +248,9 @@ let PostThreadItemLoaded = ({
|
|||
</View>
|
||||
)}
|
||||
|
||||
<Link
|
||||
<View
|
||||
testID={`postThreadItem-by-${post.author.handle}`}
|
||||
style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
|
||||
noFeedback
|
||||
accessible={false}>
|
||||
<PostSandboxWarning />
|
||||
<View style={styles.layout}>
|
||||
|
@ -370,6 +369,7 @@ let PostThreadItemLoaded = ({
|
|||
richText={richText}
|
||||
lineHeight={1.3}
|
||||
style={s.flex1}
|
||||
selectable
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
@ -445,7 +445,7 @@ let PostThreadItemLoaded = ({
|
|||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Link>
|
||||
</View>
|
||||
<WhoCanReply post={post} />
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -17,6 +17,7 @@ export function RichText({
|
|||
lineHeight = 1.2,
|
||||
style,
|
||||
numberOfLines,
|
||||
selectable,
|
||||
noLinks,
|
||||
}: {
|
||||
testID?: string
|
||||
|
@ -25,6 +26,7 @@ export function RichText({
|
|||
lineHeight?: number
|
||||
style?: StyleProp<TextStyle>
|
||||
numberOfLines?: number
|
||||
selectable?: boolean
|
||||
noLinks?: boolean
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
|
@ -44,7 +46,11 @@ export function RichText({
|
|||
}
|
||||
return (
|
||||
// @ts-ignore web only -prf
|
||||
<Text testID={testID} style={[style, pal.text]} dataSet={WORD_WRAP}>
|
||||
<Text
|
||||
testID={testID}
|
||||
style={[style, pal.text]}
|
||||
dataSet={WORD_WRAP}
|
||||
selectable={selectable}>
|
||||
{text}
|
||||
</Text>
|
||||
)
|
||||
|
@ -56,7 +62,8 @@ export function RichText({
|
|||
style={[style, pal.text, lineHeightStyle]}
|
||||
numberOfLines={numberOfLines}
|
||||
// @ts-ignore web only -prf
|
||||
dataSet={WORD_WRAP}>
|
||||
dataSet={WORD_WRAP}
|
||||
selectable={selectable}>
|
||||
{text}
|
||||
</Text>
|
||||
)
|
||||
|
@ -85,6 +92,7 @@ export function RichText({
|
|||
href={`/profile/${mention.did}`}
|
||||
style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
|
||||
dataSet={WORD_WRAP}
|
||||
selectable={selectable}
|
||||
/>,
|
||||
)
|
||||
} else if (link && AppBskyRichtextFacet.validateLink(link).success) {
|
||||
|
@ -100,6 +108,7 @@ export function RichText({
|
|||
style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
|
||||
dataSet={WORD_WRAP}
|
||||
warnOnMismatchingLabel
|
||||
selectable={selectable}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
@ -115,7 +124,8 @@ export function RichText({
|
|||
style={[style, pal.text, lineHeightStyle]}
|
||||
numberOfLines={numberOfLines}
|
||||
// @ts-ignore web only -prf
|
||||
dataSet={WORD_WRAP}>
|
||||
dataSet={WORD_WRAP}
|
||||
selectable={selectable}>
|
||||
{els}
|
||||
</Text>
|
||||
)
|
||||
|
|
|
@ -2,12 +2,15 @@ import React from 'react'
|
|||
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 {UITextView} from 'react-native-ui-text-view'
|
||||
|
||||
export type CustomTextProps = TextProps & {
|
||||
type?: TypographyVariant
|
||||
lineHeight?: number
|
||||
title?: string
|
||||
dataSet?: Record<string, string | number>
|
||||
selectable?: boolean
|
||||
}
|
||||
|
||||
export function Text({
|
||||
|
@ -17,16 +20,29 @@ export function Text({
|
|||
style,
|
||||
title,
|
||||
dataSet,
|
||||
selectable,
|
||||
...props
|
||||
}: React.PropsWithChildren<CustomTextProps>) {
|
||||
const theme = useTheme()
|
||||
const typography = theme.typography[type]
|
||||
const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined
|
||||
|
||||
if (selectable && isIOS) {
|
||||
return (
|
||||
<UITextView
|
||||
style={[s.black, typography, lineHeightStyle, style]}
|
||||
{...props}>
|
||||
{children}
|
||||
</UITextView>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<RNText
|
||||
style={[s.black, typography, lineHeightStyle, style]}
|
||||
// @ts-ignore web only -esb
|
||||
dataSet={Object.assign({tooltip: title}, dataSet || {})}
|
||||
selectable={selectable}
|
||||
{...props}>
|
||||
{children}
|
||||
</RNText>
|
||||
|
|
|
@ -18370,6 +18370,10 @@ react-native-svg@14.1.0:
|
|||
css-select "^5.1.0"
|
||||
css-tree "^1.1.3"
|
||||
|
||||
"react-native-ui-text-view@link:./modules/react-native-ui-text-view":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
react-native-url-polyfill@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/react-native-url-polyfill/-/react-native-url-polyfill-1.3.0.tgz#c1763de0f2a8c22cc3e959b654c8790622b6ef6a"
|
||||
|
|
Loading…
Reference in New Issue