diff options
Diffstat (limited to 'modules/react-native-ui-text-view/ios')
7 files changed, 406 insertions, 0 deletions
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 000000000..e669b47eb --- /dev/null +++ b/modules/react-native-ui-text-view/ios/RNUITextView-Bridging-Header.h @@ -0,0 +1,3 @@ +#import <React/RCTViewManager.h> +#import <React/RCTBridge.h> +#import <React/RCTBridge+Private.h> 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 000000000..9c21d45b5 --- /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 000000000..c341c46e4 --- /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 000000000..09119a369 --- /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 000000000..9a6f0285c --- /dev/null +++ b/modules/react-native-ui-text-view/ios/RNUITextViewManager.m @@ -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 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 000000000..297bcbbb2 --- /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 000000000..4f3eda43c --- /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<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)) + } +} |