ExpandableLabel.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. //
  2. // ExpandableLabel.swift
  3. //
  4. // Copyright (c) 2015 apploft. GmbH
  5. //
  6. // Permission is hereby granted, free of charge, to any person obtaining a copy
  7. // of this software and associated documentation files (the "Software"), to deal
  8. // in the Software without restriction, including without limitation the rights
  9. // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  10. // copies of the Software, and to permit persons to whom the Software is
  11. // furnished to do so, subject to the following conditions:
  12. //
  13. // The above copyright notice and this permission notice shall be included in
  14. // all copies or substantial portions of the Software.
  15. //
  16. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  17. // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  18. // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  19. // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  20. // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  21. // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  22. // THE SOFTWARE.
  23. typealias LineIndexTuple = (line: CTLine, index: Int)
  24. import UIKit
  25. /**
  26. * The delegate of ExpandableLabel.
  27. */
  28. @objc public protocol ExpandableLabelDelegate: NSObjectProtocol {
  29. @objc func willExpandLabel(_ label: ExpandableLabel)
  30. @objc func didExpandLabel(_ label: ExpandableLabel)
  31. @objc func willCollapseLabel(_ label: ExpandableLabel)
  32. @objc func didCollapseLabel(_ label: ExpandableLabel)
  33. }
  34. /**
  35. * ExpandableLabel
  36. */
  37. @objc open class ExpandableLabel: UILabel {
  38. public enum TextReplacementType {
  39. case character
  40. case word
  41. }
  42. /// The delegate of ExpandableLabel
  43. @objc weak open var delegate: ExpandableLabelDelegate?
  44. /// Set 'true' if the label should be collapsed or 'false' for expanded.
  45. @IBInspectable open var collapsed: Bool = true {
  46. didSet {
  47. super.attributedText = (collapsed) ? self.collapsedText : self.expandedText
  48. super.numberOfLines = (collapsed) ? self.collapsedNumberOfLines : 0
  49. if let animationView = animationView {
  50. UIView.animate(withDuration: 0.5) {
  51. animationView.layoutIfNeeded()
  52. }
  53. }
  54. }
  55. }
  56. /// Set 'true' if the label can be expanded or 'false' if not.
  57. /// The default value is 'true'.
  58. @IBInspectable open var shouldExpand: Bool = true
  59. /// Set 'true' if the label can be collapsed or 'false' if not.
  60. /// The default value is 'false'.
  61. @IBInspectable open var shouldCollapse: Bool = false
  62. /// Set the link name (and attributes) that is shown when collapsed.
  63. /// The default value is "More". Cannot be nil.
  64. @objc open var collapsedAttributedLink: NSAttributedString! {
  65. didSet {
  66. self.collapsedAttributedLink = collapsedAttributedLink.copyWithAddedFontAttribute(font)
  67. }
  68. }
  69. /// Set the link name (and attributes) that is shown when expanded.
  70. /// The default value is "Less". Can be nil.
  71. @objc open var expandedAttributedLink: NSAttributedString?
  72. /// Set the ellipsis that appears just after the text and before the link.
  73. /// The default value is "...". Can be nil.
  74. @objc open var ellipsis: NSAttributedString? {
  75. didSet {
  76. self.ellipsis = ellipsis?.copyWithAddedFontAttribute(font)
  77. }
  78. }
  79. /// Set a view to animate changes of the label collapsed state with. If this value is nil, no animation occurs.
  80. /// Usually you assign the superview of this label or a UIScrollView in which this label sits.
  81. /// Also don't forget to set the contentMode of this label to top to smoothly reveal the hidden lines.
  82. /// The default value is 'nil'.
  83. @objc open var animationView: UIView?
  84. open var textReplacementType: TextReplacementType = .word
  85. private var collapsedText: NSAttributedString?
  86. private var linkHighlighted: Bool = false
  87. private let touchSize = CGSize(width: 44, height: 44)
  88. private var linkRect: CGRect?
  89. private var collapsedNumberOfLines: NSInteger = 0
  90. private var expandedLinkPosition: NSTextAlignment?
  91. private var collapsedLinkTextRange: NSRange?
  92. private var expandedLinkTextRange: NSRange?
  93. open override var numberOfLines: NSInteger {
  94. didSet {
  95. collapsedNumberOfLines = numberOfLines
  96. }
  97. }
  98. @objc public required init?(coder aDecoder: NSCoder) {
  99. super.init(coder: aDecoder)
  100. commonInit()
  101. }
  102. @objc public override init(frame: CGRect) {
  103. super.init(frame: frame)
  104. self.commonInit()
  105. }
  106. @objc public init() {
  107. super.init(frame: .zero)
  108. }
  109. open override var text: String? {
  110. set(text) {
  111. if let text = text {
  112. self.attributedText = NSAttributedString(string: text)
  113. } else {
  114. self.attributedText = nil
  115. }
  116. }
  117. get {
  118. return self.attributedText?.string
  119. }
  120. }
  121. open private(set) var expandedText: NSAttributedString?
  122. open override var attributedText: NSAttributedString? {
  123. set(attributedText) {
  124. if let attributedText = attributedText?.copyWithAddedFontAttribute(font).copyWithParagraphAttribute(font),
  125. attributedText.length > 0 {
  126. self.collapsedText = getCollapsedText(for: attributedText, link: (linkHighlighted) ? collapsedAttributedLink.copyWithHighlightedColor() : self.collapsedAttributedLink)
  127. self.expandedText = getExpandedText(for: attributedText, link: (linkHighlighted) ? expandedAttributedLink?.copyWithHighlightedColor() : self.expandedAttributedLink)
  128. super.attributedText = (self.collapsed) ? self.collapsedText : self.expandedText
  129. } else {
  130. self.expandedText = nil
  131. self.collapsedText = nil
  132. super.attributedText = nil
  133. }
  134. }
  135. get {
  136. return super.attributedText
  137. }
  138. }
  139. open func setLessLinkWith(lessLink: String, attributes: [NSAttributedString.Key: AnyObject], position: NSTextAlignment?) {
  140. var alignedattributes = attributes
  141. if let pos = position {
  142. expandedLinkPosition = pos
  143. let titleParagraphStyle = NSMutableParagraphStyle()
  144. titleParagraphStyle.alignment = pos
  145. alignedattributes[.paragraphStyle] = titleParagraphStyle
  146. }
  147. expandedAttributedLink = NSMutableAttributedString(string: lessLink,
  148. attributes: alignedattributes)
  149. }
  150. }
  151. // MARK: - Touch Handling
  152. extension ExpandableLabel {
  153. open override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
  154. setLinkHighlighted(touches, event: event, highlighted: true)
  155. }
  156. open override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
  157. setLinkHighlighted(touches, event: event, highlighted: false)
  158. }
  159. open override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
  160. guard let touch = touches.first else {
  161. return
  162. }
  163. if !collapsed {
  164. guard let range = self.expandedLinkTextRange else {
  165. return
  166. }
  167. if shouldCollapse && check(touch: touch, isInRange: range) {
  168. delegate?.willCollapseLabel(self)
  169. collapsed = true
  170. delegate?.didCollapseLabel(self)
  171. linkHighlighted = isHighlighted
  172. setNeedsDisplay()
  173. }
  174. } else {
  175. if shouldExpand && setLinkHighlighted(touches, event: event, highlighted: false) {
  176. delegate?.willExpandLabel(self)
  177. collapsed = false
  178. delegate?.didExpandLabel(self)
  179. }
  180. }
  181. }
  182. open override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
  183. setLinkHighlighted(touches, event: event, highlighted: false)
  184. }
  185. }
  186. // MARK: Privates
  187. extension ExpandableLabel {
  188. private func commonInit() {
  189. isUserInteractionEnabled = true
  190. lineBreakMode = .byClipping
  191. collapsedNumberOfLines = numberOfLines
  192. expandedAttributedLink = nil
  193. collapsedAttributedLink = NSAttributedString(string: "More", attributes: [.font: UIFont.boldSystemFont(ofSize: font.pointSize)])
  194. ellipsis = NSAttributedString(string: "...")
  195. }
  196. private func textReplaceWordWithLink(_ lineIndex: LineIndexTuple, text: NSAttributedString, linkName: NSAttributedString) -> NSAttributedString {
  197. let lineText = text.text(for: lineIndex.line)
  198. var lineTextWithLink = lineText
  199. (lineText.string as NSString).enumerateSubstrings(in: NSRange(location: 0, length: lineText.length), options: [.byWords, .reverse]) { (word, subRange, enclosingRange, stop) -> Void in
  200. let lineTextWithLastWordRemoved = lineText.attributedSubstring(from: NSRange(location: 0, length: subRange.location))
  201. let lineTextWithAddedLink = NSMutableAttributedString(attributedString: lineTextWithLastWordRemoved)
  202. if let ellipsis = self.ellipsis {
  203. lineTextWithAddedLink.append(ellipsis)
  204. lineTextWithAddedLink.append(NSAttributedString(string: " ", attributes: [.font: self.font]))
  205. }
  206. lineTextWithAddedLink.append(linkName)
  207. let fits = self.textFitsWidth(lineTextWithAddedLink)
  208. if fits {
  209. lineTextWithLink = lineTextWithAddedLink
  210. let lineTextWithLastWordRemovedRect = lineTextWithLastWordRemoved.boundingRect(for: self.frame.size.width)
  211. let wordRect = linkName.boundingRect(for: self.frame.size.width)
  212. let width = lineTextWithLastWordRemoved.string == "" ? self.frame.width : wordRect.size.width
  213. self.linkRect = CGRect(x: lineTextWithLastWordRemovedRect.size.width, y: self.font.lineHeight * CGFloat(lineIndex.index), width: width, height: wordRect.size.height)
  214. stop.pointee = true
  215. }
  216. }
  217. return lineTextWithLink
  218. }
  219. private func textReplaceWithLink(_ lineIndex: LineIndexTuple, text: NSAttributedString, linkName: NSAttributedString) -> NSAttributedString {
  220. let lineText = text.text(for: lineIndex.line)
  221. let lineTextTrimmedNewLines = NSMutableAttributedString()
  222. lineTextTrimmedNewLines.append(lineText)
  223. let nsString = lineTextTrimmedNewLines.string as NSString
  224. let range = nsString.rangeOfCharacter(from: CharacterSet.newlines)
  225. if range.length > 0 {
  226. lineTextTrimmedNewLines.replaceCharacters(in: range, with: "")
  227. }
  228. let linkText = NSMutableAttributedString()
  229. if let ellipsis = self.ellipsis {
  230. linkText.append(ellipsis)
  231. linkText.append(NSAttributedString(string: " ", attributes: [.font: self.font]))
  232. }
  233. linkText.append(linkName)
  234. let lengthDifference = lineTextTrimmedNewLines.string.composedCount - linkText.string.composedCount
  235. let truncatedString = lineTextTrimmedNewLines.attributedSubstring(
  236. from: NSMakeRange(0, lengthDifference >= 0 ? lengthDifference : lineTextTrimmedNewLines.string.composedCount))
  237. let lineTextWithLink = NSMutableAttributedString(attributedString: truncatedString)
  238. lineTextWithLink.append(linkText)
  239. return lineTextWithLink
  240. }
  241. private func getExpandedText(for text: NSAttributedString?, link: NSAttributedString?) -> NSAttributedString? {
  242. guard let text = text else { return nil }
  243. let expandedText = NSMutableAttributedString()
  244. expandedText.append(text)
  245. if let link = link, textWillBeTruncated(expandedText) {
  246. let spaceOrNewLine = expandedLinkPosition == nil ? " " : "\n"
  247. expandedText.append(NSAttributedString(string: "\(spaceOrNewLine)"))
  248. expandedText.append(NSMutableAttributedString(string: "\(link.string)", attributes: link.attributes(at: 0, effectiveRange: nil)).copyWithAddedFontAttribute(font))
  249. expandedLinkTextRange = NSMakeRange(expandedText.length - link.length, link.length)
  250. }
  251. return expandedText
  252. }
  253. private func getCollapsedText(for text: NSAttributedString?, link: NSAttributedString) -> NSAttributedString? {
  254. guard let text = text else { return nil }
  255. let lines = text.lines(for: frame.size.width)
  256. if collapsedNumberOfLines > 0 && collapsedNumberOfLines < lines.count {
  257. let lastLineRef = lines[collapsedNumberOfLines-1] as CTLine
  258. var lineIndex: LineIndexTuple?
  259. var modifiedLastLineText: NSAttributedString?
  260. if self.textReplacementType == .word {
  261. lineIndex = findLineWithWords(lastLine: lastLineRef, text: text, lines: lines)
  262. if let lineIndex = lineIndex {
  263. modifiedLastLineText = textReplaceWordWithLink(lineIndex, text: text, linkName: link)
  264. }
  265. } else {
  266. lineIndex = (lastLineRef, collapsedNumberOfLines - 1)
  267. if let lineIndex = lineIndex {
  268. modifiedLastLineText = textReplaceWithLink(lineIndex, text: text, linkName: link)
  269. }
  270. }
  271. if let lineIndex = lineIndex, let modifiedLastLineText = modifiedLastLineText {
  272. let collapsedLines = NSMutableAttributedString()
  273. for index in 0..<lineIndex.index {
  274. collapsedLines.append(text.text(for:lines[index]))
  275. }
  276. collapsedLines.append(modifiedLastLineText)
  277. collapsedLinkTextRange = NSRange(location: collapsedLines.length - link.length, length: link.length)
  278. return collapsedLines
  279. } else {
  280. return nil
  281. }
  282. }
  283. return text
  284. }
  285. private func findLineWithWords(lastLine: CTLine, text: NSAttributedString, lines: [CTLine]) -> LineIndexTuple {
  286. var lastLineRef = lastLine
  287. var lastLineIndex = collapsedNumberOfLines - 1
  288. var lineWords = spiltIntoWords(str: text.text(for: lastLineRef).string as NSString)
  289. while lineWords.count < 2 && lastLineIndex > 0 {
  290. lastLineIndex -= 1
  291. lastLineRef = lines[lastLineIndex] as CTLine
  292. lineWords = spiltIntoWords(str: text.text(for: lastLineRef).string as NSString)
  293. }
  294. return (lastLineRef, lastLineIndex)
  295. }
  296. private func spiltIntoWords(str: NSString) -> [String] {
  297. var strings: [String] = []
  298. str.enumerateSubstrings(in: NSRange(location: 0, length: str.length), options: [.byWords, .reverse]) { (word, subRange, enclosingRange, stop) -> Void in
  299. if let unwrappedWord = word {
  300. strings.append(unwrappedWord)
  301. }
  302. if strings.count > 1 { stop.pointee = true }
  303. }
  304. return strings
  305. }
  306. private func textFitsWidth(_ text: NSAttributedString) -> Bool {
  307. return (text.boundingRect(for: frame.size.width).size.height <= font.lineHeight) as Bool
  308. }
  309. private func textWillBeTruncated(_ text: NSAttributedString) -> Bool {
  310. let lines = text.lines(for: frame.size.width)
  311. return collapsedNumberOfLines > 0 && collapsedNumberOfLines < lines.count
  312. }
  313. private func textClicked(touches: Set<UITouch>?, event: UIEvent?) -> Bool {
  314. let touch = event?.allTouches?.first
  315. let location = touch?.location(in: self)
  316. let textRect = self.attributedText?.boundingRect(for: self.frame.width)
  317. if let location = location, let textRect = textRect {
  318. let finger = CGRect(x: location.x-touchSize.width/2, y: location.y-touchSize.height/2, width: touchSize.width, height: touchSize.height)
  319. if finger.intersects(textRect) {
  320. return true
  321. }
  322. }
  323. return false
  324. }
  325. @discardableResult private func setLinkHighlighted(_ touches: Set<UITouch>?, event: UIEvent?, highlighted: Bool) -> Bool {
  326. guard let touch = touches?.first else {
  327. return false
  328. }
  329. guard let range = self.collapsedLinkTextRange else {
  330. return false
  331. }
  332. if collapsed && check(touch: touch, isInRange: range) {
  333. linkHighlighted = highlighted
  334. setNeedsDisplay()
  335. return true
  336. }
  337. return false
  338. }
  339. }
  340. // MARK: Convenience Methods
  341. private extension NSAttributedString {
  342. func hasFontAttribute() -> Bool {
  343. guard !self.string.isEmpty else { return false }
  344. let font = self.attribute(.font, at: 0, effectiveRange: nil) as? UIFont
  345. return font != nil
  346. }
  347. func copyWithParagraphAttribute(_ font: UIFont) -> NSAttributedString {
  348. let paragraphStyle = NSMutableParagraphStyle()
  349. paragraphStyle.lineHeightMultiple = 1.05
  350. paragraphStyle.alignment = .left
  351. paragraphStyle.lineSpacing = 0.0
  352. paragraphStyle.minimumLineHeight = font.lineHeight
  353. paragraphStyle.maximumLineHeight = font.lineHeight
  354. let copy = NSMutableAttributedString(attributedString: self)
  355. let range = NSRange(location: 0, length: copy.length)
  356. copy.addAttribute(.paragraphStyle, value: paragraphStyle, range: range)
  357. copy.addAttribute(.baselineOffset, value: font.pointSize * 0.08, range: range)
  358. return copy
  359. }
  360. func copyWithAddedFontAttribute(_ font: UIFont) -> NSAttributedString {
  361. if !hasFontAttribute() {
  362. let copy = NSMutableAttributedString(attributedString: self)
  363. copy.addAttribute(.font, value: font, range: NSRange(location: 0, length: copy.length))
  364. return copy
  365. }
  366. return self.copy() as! NSAttributedString
  367. }
  368. func copyWithHighlightedColor() -> NSAttributedString {
  369. let alphaComponent = CGFloat(0.5)
  370. let baseColor: UIColor = (self.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? UIColor)?.withAlphaComponent(alphaComponent) ??
  371. UIColor.black.withAlphaComponent(alphaComponent)
  372. let highlightedCopy = NSMutableAttributedString(attributedString: self)
  373. let range = NSRange(location: 0, length: highlightedCopy.length)
  374. highlightedCopy.removeAttribute(.foregroundColor, range: range)
  375. highlightedCopy.addAttribute(.foregroundColor, value: baseColor, range: range)
  376. return highlightedCopy
  377. }
  378. func lines(for width: CGFloat) -> [CTLine] {
  379. let path = UIBezierPath(rect: CGRect(x: 0, y: 0, width: width, height: .greatestFiniteMagnitude))
  380. let frameSetterRef: CTFramesetter = CTFramesetterCreateWithAttributedString(self as CFAttributedString)
  381. let frameRef: CTFrame = CTFramesetterCreateFrame(frameSetterRef, CFRange(location: 0, length: 0), path.cgPath, nil)
  382. let linesNS: NSArray = CTFrameGetLines(frameRef)
  383. let linesAO: [AnyObject] = linesNS as [AnyObject]
  384. let lines: [CTLine] = linesAO as! [CTLine]
  385. return lines
  386. }
  387. func text(for lineRef: CTLine) -> NSAttributedString {
  388. let lineRangeRef: CFRange = CTLineGetStringRange(lineRef)
  389. let range: NSRange = NSRange(location: lineRangeRef.location, length: lineRangeRef.length)
  390. return self.attributedSubstring(from: range)
  391. }
  392. func boundingRect(for width: CGFloat) -> CGRect {
  393. return self.boundingRect(with: CGSize(width: width, height: .greatestFiniteMagnitude),
  394. options: .usesLineFragmentOrigin, context: nil)
  395. }
  396. }
  397. extension String {
  398. var composedCount : Int {
  399. var count = 0
  400. enumerateSubstrings(in: startIndex..<endIndex, options: .byComposedCharacterSequences) { _,_,_,_ in count += 1 }
  401. return count
  402. }
  403. }
  404. extension UILabel {
  405. open func check(touch: UITouch, isInRange targetRange: NSRange) -> Bool {
  406. let touchPoint = touch.location(in: self)
  407. let index = characterIndex(at: touchPoint)
  408. return NSLocationInRange(index, targetRange)
  409. }
  410. private func characterIndex(at touchPoint: CGPoint) -> Int {
  411. guard let attributedString = attributedText else { return NSNotFound }
  412. if !bounds.contains(touchPoint) {
  413. return NSNotFound
  414. }
  415. let textRect = self.textRect(forBounds: bounds, limitedToNumberOfLines: numberOfLines)
  416. if !textRect.contains(touchPoint) {
  417. return NSNotFound
  418. }
  419. var point = touchPoint
  420. // Offset tap coordinates by textRect origin to make them relative to the origin of frame
  421. point = CGPoint(x: point.x - textRect.origin.x, y: point.y - textRect.origin.y)
  422. // Convert tap coordinates (start at top left) to CT coordinates (start at bottom left)
  423. point = CGPoint(x: point.x, y: textRect.size.height - point.y)
  424. let framesetter = CTFramesetterCreateWithAttributedString(attributedString)
  425. let suggestedSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRangeMake(0, attributedString.length), nil, CGSize(width: textRect.width, height: CGFloat.greatestFiniteMagnitude), nil)
  426. let path = CGMutablePath()
  427. path.addRect(CGRect(x: 0, y: 0, width: suggestedSize.width, height: CGFloat(ceilf(Float(suggestedSize.height)))))
  428. let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attributedString.length), path, nil)
  429. let lines = CTFrameGetLines(frame)
  430. let linesCount = numberOfLines > 0 ? min(numberOfLines, CFArrayGetCount(lines)) : CFArrayGetCount(lines)
  431. if linesCount == 0 {
  432. return NSNotFound
  433. }
  434. var lineOrigins = [CGPoint](repeating: .zero, count: linesCount)
  435. CTFrameGetLineOrigins(frame, CFRangeMake(0, linesCount), &lineOrigins)
  436. for (idx, lineOrigin) in lineOrigins.enumerated() {
  437. var lineOrigin = lineOrigin
  438. let lineIndex = CFIndex(idx)
  439. let line = unsafeBitCast(CFArrayGetValueAtIndex(lines, lineIndex), to: CTLine.self)
  440. // Get bounding information of line
  441. var ascent: CGFloat = 0.0
  442. var descent: CGFloat = 0.0
  443. var leading: CGFloat = 0.0
  444. let width = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, &leading))
  445. let yMin = CGFloat(floor(lineOrigin.y - descent))
  446. let yMax = CGFloat(ceil(lineOrigin.y + ascent))
  447. // Apply penOffset using flushFactor for horizontal alignment to set lineOrigin since this is the horizontal offset from drawFramesetter
  448. let flushFactor = flushFactorForTextAlignment(textAlignment: textAlignment)
  449. let penOffset = CGFloat(CTLineGetPenOffsetForFlush(line, flushFactor, Double(textRect.size.width)))
  450. lineOrigin.x = penOffset
  451. // Check if we've already passed the line
  452. if point.y > yMax {
  453. return NSNotFound
  454. }
  455. // Check if the point is within this line vertically
  456. if point.y >= yMin {
  457. // Check if the point is within this line horizontally
  458. if point.x >= lineOrigin.x && point.x <= lineOrigin.x + width {
  459. // Convert CT coordinates to line-relative coordinates
  460. let relativePoint = CGPoint(x: point.x - lineOrigin.x, y: point.y - lineOrigin.y)
  461. return Int(CTLineGetStringIndexForPosition(line, relativePoint))
  462. }
  463. }
  464. }
  465. return NSNotFound
  466. }
  467. private func flushFactorForTextAlignment(textAlignment: NSTextAlignment) -> CGFloat {
  468. switch textAlignment {
  469. case .center:
  470. return 0.5
  471. case .right:
  472. return 1.0
  473. case .left, .natural, .justified:
  474. return 0.0
  475. }
  476. }
  477. }