about summary refs log tree commit diff
path: root/modules/react-native-ui-text-view/ios
diff options
context:
space:
mode:
Diffstat (limited to 'modules/react-native-ui-text-view/ios')
-rw-r--r--modules/react-native-ui-text-view/ios/RNUITextView-Bridging-Header.h3
-rw-r--r--modules/react-native-ui-text-view/ios/RNUITextView.swift141
-rw-r--r--modules/react-native-ui-text-view/ios/RNUITextViewChild.swift4
-rw-r--r--modules/react-native-ui-text-view/ios/RNUITextViewChildShadow.swift56
-rw-r--r--modules/react-native-ui-text-view/ios/RNUITextViewManager.m25
-rw-r--r--modules/react-native-ui-text-view/ios/RNUITextViewManager.swift30
-rw-r--r--modules/react-native-ui-text-view/ios/RNUITextViewShadow.swift147
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))
+  }
+}