11//
2- // CollapsibleLabel .swift
2+ // CollapsibleTextView .swift
33// Zotero
44//
5- // Created by Michal Rentka on 02/10/2020 .
6- // Copyright © 2020 Corporation for Digital Scholarship. All rights reserved.
5+ // Created by Michal Rentka on 09/04/2026 .
6+ // Copyright © 2026 Corporation for Digital Scholarship. All rights reserved.
77//
88
99import UIKit
1010
11- final class CollapsibleLabel : UILabel {
11+ final class CollapsibleTextView : UITextView {
12+ static let toggleURL = URL ( string: " zotero://toggle-collapse " ) !
13+
1214 var collapsedNumberOfLines : Int = 0
1315 var showMoreString : NSAttributedString ?
1416 var showLessString : NSAttributedString ?
17+ var onToggle : ( ( ) -> Void ) ?
1518
1619 private var isCollapsed = false
20+ private var hasCollapsedVersion = false
1721 private var collapsedString : NSAttributedString ?
1822 private var expandedString : NSAttributedString ?
1923 private var originalString : NSAttributedString ?
2024 private var maxWidth : CGFloat = 0
25+ private var collapsedContextMenuInteraction : UIContextMenuInteraction ?
26+
27+ private var isShowingCollapsedVersion : Bool {
28+ return isCollapsed && hasCollapsedVersion
29+ }
30+
31+ override init ( frame: CGRect , textContainer: NSTextContainer ? ) {
32+ super. init ( frame: frame, textContainer: textContainer)
33+ setupTextView ( )
34+ }
35+
36+ required init ? ( coder: NSCoder ) {
37+ super. init ( coder: coder)
38+ setupTextView ( )
39+ }
40+
41+ override var canBecomeFirstResponder : Bool {
42+ // Disable text selection while collapsed; link taps still work because they don't require first-responder.
43+ return !isShowingCollapsedVersion && super. canBecomeFirstResponder
44+ }
45+
46+ private func setupTextView( ) {
47+ isEditable = false
48+ isSelectable = true
49+ isScrollEnabled = false
50+ textContainerInset = . zero
51+ self . textContainer. lineFragmentPadding = 0
52+ backgroundColor = . clear
53+ delegate = self
54+ linkTextAttributes = [ . foregroundColor: Asset . Colors. zoteroBlue. color]
55+ }
2156
2257 func set( text: NSAttributedString , isCollapsed: Bool , maxWidth: CGFloat ) {
2358 if ( originalString != text) || ( self . maxWidth != maxWidth) {
@@ -27,35 +62,48 @@ final class CollapsibleLabel: UILabel {
2762 }
2863 attributedText = isCollapsed ? collapsedString : expandedString
2964 self . isCollapsed = isCollapsed
65+ updateCollapsedInteraction ( )
3066
31- /// Creates `collapsedString` and `expandedString` from given text.
32- /// - parameter text: Text to adjust.
3367 func createStrings( from text: NSAttributedString , maxWidth: CGFloat ) {
3468 if let string = createCollapsedString ( from: text, maxWidth: maxWidth) {
3569 collapsedString = string
3670 expandedString = createExpandedString ( from: text, maxWidth: maxWidth) ?? text
71+ hasCollapsedVersion = true
3772 } else {
3873 collapsedString = text
3974 expandedString = text
75+ hasCollapsedVersion = false
4076 }
4177
42- /// Creates a "collapsed" version of given string. Collapsed string appends a `showMoreString` at the last line, limited by `collapsedNumberOfLines`, if needed.
43- /// - parameter string: String to collapse.
44- /// - returns: An `NSAttributedString` with appended `showMoreString` if there are more than `collapsedNumberOfLines`, `nil` otherwise.
4578 func createCollapsedString( from string: NSAttributedString , maxWidth: CGFloat ) -> NSAttributedString ? {
4679 guard let showMoreString, !string. string. isEmpty, collapsedNumberOfLines > 0 else { return nil }
4780 return fit ( attributedString: showMoreString, toLastLineOf: string, lineLimit: collapsedNumberOfLines, maxWidth: maxWidth)
4881 }
4982
50- /// Creates an "expanded" version of given string. Expanded string appends a `showLessString` at a new line.
51- /// - returns: An `NSAttributedString` with appended `showLessString` if `showLessString` is available, `nil` otherwise.
5283 func createExpandedString( from string: NSAttributedString , maxWidth: CGFloat ) -> NSAttributedString ? {
5384 guard let showLessString else { return nil }
5485 return fit ( attributedString: showLessString, toLastLineOf: string, lineLimit: nil , maxWidth: maxWidth)
5586 }
5687 }
5788 }
5889
90+ private func updateCollapsedInteraction( ) {
91+ if isShowingCollapsedVersion {
92+ if isFirstResponder {
93+ selectedTextRange = nil
94+ resignFirstResponder ( )
95+ }
96+ if collapsedContextMenuInteraction == nil {
97+ let interaction = UIContextMenuInteraction ( delegate: self )
98+ addInteraction ( interaction)
99+ collapsedContextMenuInteraction = interaction
100+ }
101+ } else if let interaction = collapsedContextMenuInteraction {
102+ removeInteraction ( interaction)
103+ collapsedContextMenuInteraction = nil
104+ }
105+ }
106+
59107 private func fit( attributedString stringToFit: NSAttributedString , toLastLineOf string: NSAttributedString , lineLimit: Int ? , maxWidth: CGFloat ) -> NSAttributedString ? {
60108 guard let lines = string. lines ( for: maxWidth) else { return nil }
61109 guard let limit = lineLimit else {
@@ -108,7 +156,7 @@ final class CollapsibleLabel: UILabel {
108156
109157 // If it doesn't fit, go word by word and check whether it fits without given word
110158 let nsString = line. string as NSString
111- nsString. enumerateSubstrings ( in: _NSRange ( location: 0 , length: line. length) , options: [ . byWords, . reverse] ) { _, subrange, _, stop in
159+ nsString. enumerateSubstrings ( in: NSRange ( location: 0 , length: line. length) , options: [ . byWords, . reverse] ) { _, subrange, _, stop in
112160 let length : Int
113161 if subrange. location == 0 {
114162 length = 0
@@ -150,6 +198,60 @@ final class CollapsibleLabel: UILabel {
150198 }
151199}
152200
201+ extension CollapsibleTextView : UIContextMenuInteractionDelegate {
202+ func contextMenuInteraction( _ interaction: UIContextMenuInteraction , configurationForMenuAtLocation location: CGPoint ) -> UIContextMenuConfiguration ? {
203+ guard let text = originalString? . string, !text. isEmpty else { return nil }
204+ return UIContextMenuConfiguration ( identifier: nil , previewProvider: nil ) { _ in
205+ UIMenu ( title: " " , children: [
206+ UIAction ( title: L10n . copy) { _ in
207+ UIPasteboard . general. string = text
208+ }
209+ ] )
210+ }
211+ }
212+ }
213+
214+ extension CollapsibleTextView : UITextViewDelegate {
215+ func textView( _ textView: UITextView , shouldInteractWith URL: URL , in characterRange: NSRange , interaction: UITextItemInteraction ) -> Bool {
216+ if URL == Self . toggleURL {
217+ if interaction == . invokeDefaultAction {
218+ onToggle ? ( )
219+ }
220+ return false
221+ }
222+ return true
223+ }
224+
225+ @available ( iOS 17 . 0 , * )
226+ func textView( _ textView: UITextView , primaryActionFor textItem: UITextItem , defaultAction: UIAction ) -> UIAction ? {
227+ if case . link( let url) = textItem. content, url == Self . toggleURL {
228+ return UIAction { [ weak self] _ in self ? . onToggle ? ( ) }
229+ }
230+ return defaultAction
231+ }
232+
233+ @available ( iOS 17 . 0 , * )
234+ func textView( _ textView: UITextView , menuConfigurationFor textItem: UITextItem , defaultMenu: UIMenu ) -> UITextItem . MenuConfiguration ? {
235+ if case . link( let url) = textItem. content, url == Self . toggleURL {
236+ return nil
237+ }
238+ return . init( menu: defaultMenu)
239+ }
240+
241+ func textViewDidChangeSelection( _ textView: UITextView ) {
242+ // The trailing toggle link (" show less") is appended to the displayed text and would otherwise be selectable along with the body. Trim selection back to the original text so the link can't be selected.
243+ guard let originalLength = originalString? . length else { return }
244+ let selected = textView. selectedRange
245+ guard selected. length > 0 , selected. location + selected. length > originalLength else { return }
246+ let clippedLocation = min ( selected. location, originalLength)
247+ let clippedLength = originalLength - clippedLocation
248+ let clipped = NSRange ( location: clippedLocation, length: clippedLength)
249+ if clipped != selected {
250+ textView. selectedRange = clipped
251+ }
252+ }
253+ }
254+
153255fileprivate extension NSAttributedString {
154256 func lines( for width: CGFloat ) -> [ CTLine ] ? {
155257 let path = UIBezierPath ( rect: CGRect ( x: 0 , y: 0 , width: width, height: . greatestFiniteMagnitude) )
0 commit comments