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:
@@ -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
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
public class ExpoSelectableTextUtil {
|
||||
public class TextUtil {
|
||||
public static func hexToUIColor(hex: String?) -> UIColor {
|
||||
guard let hex else {
|
||||
return UIColor.black
|
||||
Reference in New Issue
Block a user