diff --git a/.gitignore b/.gitignore index 7a6137b1..f96d0d5f 100644 --- a/.gitignore +++ b/.gitignore @@ -91,8 +91,8 @@ web-build/ # Android & iOS folders -android/ -ios/ +/android/ +/ios/ # environment variables .env diff --git a/modules/react-native-ui-text-view/README.md b/modules/react-native-ui-text-view/README.md new file mode 100644 index 00000000..b19ac896 --- /dev/null +++ b/modules/react-native-ui-text-view/README.md @@ -0,0 +1,61 @@ +# React Native UITextView + +Drop in replacement for `` 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 `` with ``. Styles and press events should be handled the same way they would +with ``. Both `` and `` are supported as children of the root ``. + +## 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` diff --git a/modules/react-native-ui-text-view/ios/RNUITextView-Bridging-Header.h b/modules/react-native-ui-text-view/ios/RNUITextView-Bridging-Header.h new file mode 100644 index 00000000..e669b47e --- /dev/null +++ b/modules/react-native-ui-text-view/ios/RNUITextView-Bridging-Header.h @@ -0,0 +1,3 @@ +#import +#import +#import diff --git a/modules/react-native-ui-text-view/ios/RNUITextView.swift b/modules/react-native-ui-text-view/ios/RNUITextView.swift new file mode 100644 index 00000000..9c21d45b --- /dev/null +++ b/modules/react-native-ui-text-view/ios/RNUITextView.swift @@ -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 + } + } +} diff --git a/modules/react-native-ui-text-view/ios/RNUITextViewChild.swift b/modules/react-native-ui-text-view/ios/RNUITextViewChild.swift new file mode 100644 index 00000000..c341c46e --- /dev/null +++ b/modules/react-native-ui-text-view/ios/RNUITextViewChild.swift @@ -0,0 +1,4 @@ +class RNUITextViewChild: UIView { + @objc var text: String? + @objc var onPress: RCTDirectEventBlock? +} diff --git a/modules/react-native-ui-text-view/ios/RNUITextViewChildShadow.swift b/modules/react-native-ui-text-view/ios/RNUITextViewChildShadow.swift new file mode 100644 index 00000000..09119a36 --- /dev/null +++ b/modules/react-native-ui-text-view/ios/RNUITextViewChildShadow.swift @@ -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 + } + } +} diff --git a/modules/react-native-ui-text-view/ios/RNUITextViewManager.m b/modules/react-native-ui-text-view/ios/RNUITextViewManager.m new file mode 100644 index 00000000..9a6f0285 --- /dev/null +++ b/modules/react-native-ui-text-view/ios/RNUITextViewManager.m @@ -0,0 +1,25 @@ +#import + +@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 diff --git a/modules/react-native-ui-text-view/ios/RNUITextViewManager.swift b/modules/react-native-ui-text-view/ios/RNUITextViewManager.swift new file mode 100644 index 00000000..297bcbbb --- /dev/null +++ b/modules/react-native-ui-text-view/ios/RNUITextViewManager.swift @@ -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() + } +} diff --git a/modules/react-native-ui-text-view/ios/RNUITextViewShadow.swift b/modules/react-native-ui-text-view/ios/RNUITextViewShadow.swift new file mode 100644 index 00000000..4f3eda43 --- /dev/null +++ b/modules/react-native-ui-text-view/ios/RNUITextViewShadow.swift @@ -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.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)) + } +} diff --git a/modules/react-native-ui-text-view/package.json b/modules/react-native-ui-text-view/package.json new file mode 100644 index 00000000..184a9014 --- /dev/null +++ b/modules/react-native-ui-text-view/package.json @@ -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" +} diff --git a/modules/react-native-ui-text-view/react-native-ui-text-view.podspec b/modules/react-native-ui-text-view/react-native-ui-text-view.podspec new file mode 100644 index 00000000..1e0dee93 --- /dev/null +++ b/modules/react-native-ui-text-view/react-native-ui-text-view.podspec @@ -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 diff --git a/modules/react-native-ui-text-view/src/UITextView.tsx b/modules/react-native-ui-text-view/src/UITextView.tsx new file mode 100644 index 00000000..bbb45dcc --- /dev/null +++ b/modules/react-native-ui-text-view/src/UITextView.tsx @@ -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 ( + + + {React.Children.toArray(children).map((c, index) => { + if (React.isValidElement(c)) { + return c + } else if (typeof c === 'string') { + return ( + + ) + } + })} + + + ) + } else { + return ( + <> + {React.Children.toArray(children).map((c, index) => { + if (React.isValidElement(c)) { + return c + } else if (typeof c === 'string') { + return ( + + ) + } + })} + + ) + } +} diff --git a/modules/react-native-ui-text-view/src/index.tsx b/modules/react-native-ui-text-view/src/index.tsx new file mode 100644 index 00000000..d5bde136 --- /dev/null +++ b/modules/react-native-ui-text-view/src/index.tsx @@ -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('RNUITextView') + : () => { + throw new Error(LINKING_ERROR) + } + +export const RNUITextViewChild = + UIManager.getViewManagerConfig && + UIManager.getViewManagerConfig('RNUITextViewChild') != null + ? requireNativeComponent('RNUITextViewChild') + : () => { + throw new Error(LINKING_ERROR) + } + +export * from './UITextView' diff --git a/package.json b/package.json index 17677fb9..258f66b9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index ee096b0d..38f2c89c 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -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 ( {altText ? ( - setAltExpanded(!isAltExpanded)} - onLongPress={() => {}} - accessibilityRole="button"> - - - {altText} - - - + + { + LayoutAnimation.configureNext({ + duration: 300, + update: {type: 'spring', springDamping: 0.7}, + }) + setAltExpanded(prev => !prev) + }}> + {altText} + + ) : null}