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
This commit is contained in:
Hailey
2024-01-15 03:12:43 -08:00
parent ec8c05f3f0
commit aa96bba066
23 changed files with 413 additions and 490 deletions
@@ -1,36 +0,0 @@
import ExpoModulesCore
public class ExpoSelectableTextModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoSelectableText")
View(ExpoSelectableTextView.self) {
Events("onTextPress", "onTextLongPress", "onTextLayout")
Prop("segments") { (view: ExpoSelectableTextView, prop: String) in
// Convert the JSON to segments
if let data = prop.data(using: .utf8) {
if let segments = try? JSONDecoder().decode(TextSegments.self, from: data) {
view.segments = segments.segments
}
}
}
Prop("rootStyle") { (view: ExpoSelectableTextView, prop: String) in
if let data = prop.data(using: .utf8) {
if let style = try? JSONDecoder().decode(TextStyle.self, from: data) {
view.style = style
}
}
}
Prop("selectable") { (view: ExpoSelectableTextView, prop: Bool) in
view.textView.isSelectable = prop
}
Prop("children") { (view: ExpoSelectableTextView, prop: JavaScriptValue) in
print(prop)
}
}
}
}
@@ -1,147 +0,0 @@
import ExpoModulesCore
class ExpoSelectableTextView: ExpoView {
var textView: UITextView
var segments: Array<TextSegment> = [] {
didSet {
// We don't want to set the text if the root style has not been set yet
self.setText()
}
}
var style: TextStyle? {
didSet {
// If the text has not been set and there are segments, set the text
self.setText()
}
}
let onTextPress = EventDispatcher()
let onTextLongPress = EventDispatcher()
let onTextLayout = EventDispatcher()
public required init(appContext: AppContext? = nil) {
if #available(iOS 16.0, *) {
textView = UITextView(usingTextLayoutManager: false)
} else {
textView = UITextView()
}
super.init(appContext: appContext)
// Configure default appearance
textView.scrollsToTop = false
textView.isEditable = false
textView.isScrollEnabled = false
textView.backgroundColor = .clear
// Remove all of the padding from the view
textView.textContainerInset = UIEdgeInsets.zero
textView.textContainer.lineFragmentPadding = 0
// Add the text view to the root view
self.addSubview(textView)
// Configure the press recognizer
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
textView.addGestureRecognizer(tapGestureRecognizer)
}
override func layoutSubviews() -> Void {
// Set the textView's frame on layout
self.setSize()
}
@IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void {
if let segment = getPressedSegment(sender), segment.handlePress {
if textView.selectedTextRange == nil {
onTextPress([
"index": segment.index
])
} else {
// Clear the selected text range if we are not pressing on a link
textView.selectedTextRange = nil
}
}
}
func setSize() -> Void {
// Figure out the height of our text and create a CGRect
let maxWidth = bounds.width
let sizeThatFits = textView.sizeThatFits(CGSize(width: maxWidth, height: CGFloat(MAXFLOAT)))
let size = CGSize(width: maxWidth, height: sizeThatFits.height)
textView.frame.size = size
self.onTextLayout([
"height": sizeThatFits.height,
"width": maxWidth
])
}
func setText() -> Void {
let finalAttributedString = NSMutableAttributedString()
self.segments.forEach { segment in
// Set some generic attributes that don't need ranges
let attributes: [NSAttributedString.Key:Any] = [
.font: UIFont.systemFont(ofSize: segment.style?.fontSize ?? self.style?.fontSize ?? 12.0, weight: segment.style?.fontWeight?.toFontWeight() ?? self.style?.fontWeight?.toFontWeight() ?? .regular),
.foregroundColor: ExpoSelectableTextUtil.hexToUIColor(hex: segment.style?.color),
]
// Create the attributed string with the generic attributes
let string = NSMutableAttributedString(string: segment.text, attributes: attributes)
// Set the paragraph style attributes if necessary
if let lineHeight = segment.style?.lineHeight {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.minimumLineHeight = lineHeight
paragraphStyle.maximumLineHeight = lineHeight
string.addAttribute(NSAttributedString.Key.paragraphStyle, value: paragraphStyle, range: NSMakeRange(0, string.length))
}
if let textDecorationLine = segment.style?.textDecorationLine {
if textDecorationLine == .underline || textDecorationLine == .underlineLineThrough {
string.addAttribute(NSAttributedString.Key.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: NSMakeRange(0, string.length))
}
if textDecorationLine == .lineThrough || textDecorationLine == .underlineLineThrough {
string.addAttribute(NSAttributedString.Key.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: NSMakeRange(0, string.length))
}
}
finalAttributedString.append(string)
}
textView.attributedText = finalAttributedString
textView.selectedTextRange = nil
self.setNeedsLayout()
}
func getPressedSegment(_ sender: UITapGestureRecognizer) -> TextSegment? {
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)
let text = textView.attributedText.string
var foundSegment: TextSegment?
// Check each segment
self.segments.forEach { segment in
let range = text.range(of: segment.text)
// Figure out the bounds
if let lowerBound = range?.lowerBound, let upperBound = range?.upperBound {
if charIndex >= lowerBound.utf16Offset(in: text), charIndex <= upperBound.utf16Offset(in: text) {
foundSegment = segment
}
}
}
return foundSegment
}
}
@@ -1,8 +1,8 @@
Pod::Spec.new do |s|
s.name = 'ExpoSelectableText'
s.name = 'ExpoUITextView'
s.version = '1.0.0'
s.summary = 'Simple wrapper for RN Text to use UITextView instead of UILabel'
s.description = 'Simple wrapper for RN Text to use UITextView instead of UILabel'
s.summary = 'Drop in replacement for RN Text that uses UITextView'
s.description = 'Drop in replacement for RN Text that uses UITextView'
s.author = ''
s.homepage = 'https://github.com/bluesky-social/social-app/modules/expo-selectable-text'
s.platform = :ios, '13.0'
@@ -0,0 +1,192 @@
import ExpoModulesCore
class ExpoUITextView: ExpoView {
var textView: UITextView
var textChildren: [ExpoUITextViewChild] = []
var tapGestureRecognizer: UITapGestureRecognizer?
public required init(appContext: AppContext? = nil) {
if #available(iOS 16.0, *) {
textView = UITextView(usingTextLayoutManager: false)
} else {
textView = UITextView()
}
// Configure default appearance
textView.scrollsToTop = false
textView.isEditable = false
textView.isScrollEnabled = false
textView.backgroundColor = .clear
textView.textContainer.lineBreakMode = .byTruncatingTail
textView.isSelectable = true
// Remove all of the padding from the view
textView.textContainerInset = UIEdgeInsets.zero
textView.textContainer.lineFragmentPadding = 0
super.init(appContext: appContext)
addSubview(textView)
// Configure the tap gesture recognizer
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
tapGestureRecognizer.isEnabled = true
self.tapGestureRecognizer = tapGestureRecognizer
textView.addGestureRecognizer(tapGestureRecognizer)
}
// Update children whenever new react subviews are added
override func insertReactSubview(_ subview: UIView!, at atIndex: Int) {
if subview.isKind(of: ExpoUITextViewChild.self) {
insertSubview(subview, at: atIndex)
self.getTextChildren()
}
}
// Do the same whenever subviews are removed
override func removeReactSubview(_ subview: UIView!) {
if subview.isKind(of: ExpoUITextViewChild.self) {
subview.removeFromSuperview()
}
}
override func reactSubviews() -> [UIView]! {
return subviews
}
override func layoutSubviews() {
// Get the width from the bounds
let maxWidth = bounds.width
// Calculate the size of the text
let sizeThatFits = textView.sizeThatFits(CGSize(width: maxWidth, height: CGFloat(MAXFLOAT)))
let size = CGSize(width: maxWidth, height: sizeThatFits.height)
// Set the textview's frame
textView.frame.size = size
self.appContext?.reactBridge?.uiManager.setSize(size, for: self)
}
@IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void {
// If we find a child, then call onPress
if let child = getPressed(sender) {
if textView.selectedTextRange == nil {
child.onTextPress()
} 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) -> ExpoUITextViewChild? {
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
)
let text = textView.attributedText.string
var foundChild: ExpoUITextViewChild?
// Check each segment
self.textChildren.forEach { child in
let range = text.range(of: child.text ?? "")
// Figure out the bounds
if let lowerBound = range?.lowerBound, let upperBound = range?.upperBound {
if charIndex >= lowerBound.utf16Offset(in: text), charIndex <= upperBound.utf16Offset(in: text) {
foundChild = child
}
}
}
return foundChild
}
// Get the children. Always use getTextChildren() so that we ensure the correct order of views
func getTextChildren() -> Void {
var children: [ExpoUITextViewChild] = []
self.reactSubviews().forEach { view in
if view.isKind(of: ExpoUITextViewChild.self) {
children.append(view as! ExpoUITextViewChild)
}
}
// Save the children for our onPress handler
self.textChildren = children
// Update the UITextView with the styled text
self.setText()
}
func setText() -> Void {
// Create an attributed string to store each of the segments
let finalAttributedString = NSMutableAttributedString()
self.textChildren.forEach { child in
// If we don't have any text in this child, move to the next one
guard let text = child.text else {
return
}
// Set some generic attributes that don't need ranges
let attributes: [NSAttributedString.Key:Any] = [
.font: UIFont.systemFont(
ofSize: child.style?.fontSize ?? 12.0,
weight: child.style?.fontWeight?.toFontWeight() ?? .regular
),
.foregroundColor: TextUtil.hexToUIColor(hex: child.style?.color),
]
// Create the attributed string with the generic attributes
let string = NSMutableAttributedString(string: text, attributes: attributes)
// Set the paragraph style attributes if necessary
if let lineHeight = child.style?.lineHeight {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.minimumLineHeight = lineHeight
paragraphStyle.maximumLineHeight = lineHeight
string.addAttribute(
NSAttributedString.Key.paragraphStyle,
value: paragraphStyle,
range: NSMakeRange(0, string.length)
)
}
if let textDecorationLine = child.style?.textDecorationLine {
if textDecorationLine == .underline || textDecorationLine == .underlineLineThrough {
string.addAttribute(
NSAttributedString.Key.underlineStyle,
value: NSUnderlineStyle.single.rawValue,
range: NSMakeRange(0, string.length)
)
}
if textDecorationLine == .lineThrough || textDecorationLine == .underlineLineThrough {
string.addAttribute(
NSAttributedString.Key.strikethroughStyle,
value: NSUnderlineStyle.single.rawValue,
range: NSMakeRange(0, string.length)
)
}
}
finalAttributedString.append(string)
}
textView.attributedText = finalAttributedString
textView.selectedTextRange = nil
self.setNeedsLayout()
}
}
@@ -0,0 +1,7 @@
import ExpoModulesCore
class ExpoUITextViewChild: ExpoView {
var text: String?
var style: TextStyle?
let onTextPress = EventDispatcher()
}
@@ -0,0 +1,32 @@
import ExpoModulesCore
public class ExpoUITextViewModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoSelectableText")
View(ExpoUITextView.self) {
}
}
}
/**
Children should have parity with React Native text props. The difference is that we will use the text prop for the text rather than getting it from children.
*/
public class ExpoUITextViewChildModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoTextChild")
View(ExpoUITextViewChild.self) {
Events("onTextPress")
Prop("text") { (view: ExpoUITextViewChild, prop: String) in
view.text = prop
}
Prop("textStyle") { (view: ExpoUITextViewChild, prop: TextStyle) in
view.style = prop
}
}
}
}
@@ -1,13 +1,15 @@
import ExpoModulesCore
struct TextSegments: Decodable {
let segments: Array<TextSegment>
struct TextSegments: Record {
@Field
var segments: Array<TextSegment>
}
struct TextSegment: Decodable {
let index: Int
let text: String
let style: TextStyle?
let handlePress: Bool
let handleLongPress: Bool
struct TextSegment: Record {
@Field
var index: Int
@Field
var text: String
@Field
var style: TextStyle?
}
@@ -1,16 +1,29 @@
struct TextStyle: Decodable {
import ExpoModulesCore
struct TextStyle: Record {
@Field
var color: String? = "black"
@Field
var fontSize: CGFloat? = 12
@Field
var fontStyle: String? = "normal"
@Field
var fontWeight: FontWeight?
@Field
var letterSpacing: Double?
@Field
var textAlign: String? = "auto"
@Field
var lineHeight: Double?
@Field
var textDecorationLine: TextDecorationLine?
@Field
var flex: Int?
@Field
var pointerEvents: String?
}
enum FontWeight: String, Decodable {
enum FontWeight: String, Enumerable {
case bold
case normal
case one = "100"
@@ -51,10 +64,9 @@ enum FontWeight: String, Decodable {
}
}
enum TextDecorationLine: String, Decodable {
enum TextDecorationLine: String, Enumerable {
case underline
case lineThrough = "line-through"
case underlineLineThrough = "underline line-through"
case normal
}
@@ -1,4 +1,4 @@
public class ExpoSelectableTextUtil {
public class TextUtil {
public static func hexToUIColor(hex: String?) -> UIColor {
guard let hex else {
return UIColor.black