Skip to content

Commit d658086

Browse files
authored
Converted item detail abstract to UITextView so that the abstract is selectable (#1264)
1 parent bdd6414 commit d658086

6 files changed

Lines changed: 184 additions & 48 deletions

File tree

Zotero.xcodeproj/project.pbxproj

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,7 +1089,8 @@
10891089
B3D0994726DCCEF700B04FAA /* RPathCoordinate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D0994626DCCEF700B04FAA /* RPathCoordinate.swift */; };
10901090
B3D0994826DCCF6500B04FAA /* RPathCoordinate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D0994626DCCEF700B04FAA /* RPathCoordinate.swift */; };
10911091
B3D0AC98255C14AB007648F5 /* PDFReaderLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D0AC97255C14AB007648F5 /* PDFReaderLayout.swift */; };
1092-
B3D1ED1F2527282400C31465 /* CollapsibleLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D1ED1E2527282300C31465 /* CollapsibleLabel.swift */; };
1092+
B3D1ED212527282600C31465 /* CollapsibleTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D1ED202527282500C31465 /* CollapsibleTextView.swift */; };
1093+
B3D32A4D286C77850075C6D7 /* ItemSortingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D32A4C286C77850075C6D7 /* ItemSortingView.swift */; };
10931094
B3D3FCA9267762EC008E243A /* ExportLocaleReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D3FCA8267762EC008E243A /* ExportLocaleReader.swift */; };
10941095
B3D4159E2948B3DA004ABB3E /* FixNotesWithEmptyTitlesDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D4159D2948B3DA004ABB3E /* FixNotesWithEmptyTitlesDbRequest.swift */; };
10951096
B3D427D62CB67EFC0058453A /* AutoEmptyTrashController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D427D52CB67EEA0058453A /* AutoEmptyTrashController.swift */; };
@@ -2313,7 +2314,8 @@
23132314
B3D0793527CCF63800C454D6 /* AnnotationBoundingBoxCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationBoundingBoxCalculator.swift; sourceTree = "<group>"; };
23142315
B3D0994626DCCEF700B04FAA /* RPathCoordinate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RPathCoordinate.swift; sourceTree = "<group>"; };
23152316
B3D0AC97255C14AB007648F5 /* PDFReaderLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFReaderLayout.swift; sourceTree = "<group>"; };
2316-
B3D1ED1E2527282300C31465 /* CollapsibleLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleLabel.swift; sourceTree = "<group>"; };
2317+
B3D1ED202527282500C31465 /* CollapsibleTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleTextView.swift; sourceTree = "<group>"; };
2318+
B3D32A4C286C77850075C6D7 /* ItemSortingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSortingView.swift; sourceTree = "<group>"; };
23172319
B3D3FCA8267762EC008E243A /* ExportLocaleReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportLocaleReader.swift; sourceTree = "<group>"; };
23182320
B3D4159D2948B3DA004ABB3E /* FixNotesWithEmptyTitlesDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FixNotesWithEmptyTitlesDbRequest.swift; sourceTree = "<group>"; };
23192321
B3D427D52CB67EEA0058453A /* AutoEmptyTrashController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoEmptyTrashController.swift; sourceTree = "<group>"; };
@@ -3604,7 +3606,7 @@
36043606
B3593ECE241A61C700760E20 /* Views */ = {
36053607
isa = PBXGroup;
36063608
children = (
3607-
B3D1ED1E2527282300C31465 /* CollapsibleLabel.swift */,
3609+
B3D1ED202527282500C31465 /* CollapsibleTextView.swift */,
36083610
B32A273A254841B80081E061 /* CreatorEditViewController.swift */,
36093611
B32A273B254841B80081E061 /* CreatorEditViewController.xib */,
36103612
B3593ED4241A61C700760E20 /* ItemDetailAbstractCell.swift */,
@@ -6014,7 +6016,7 @@
60146016
B30565BB23FC051E003304F2 /* ReadItemDbRequest.swift in Sources */,
60156017
B305660623FC051E003304F2 /* UpdatesRequest.swift in Sources */,
60166018
B3F3D636255EC8F300F310C2 /* AnnotationViewImageContent.swift in Sources */,
6017-
B3D1ED1F2527282400C31465 /* CollapsibleLabel.swift in Sources */,
6019+
B3D1ED212527282600C31465 /* CollapsibleTextView.swift in Sources */,
60186020
B302DC54293A22A6003497D9 /* FixChildItemsWithCollectionsDbRequest.swift in Sources */,
60196021
B372CEE32486512500B423AE /* GroupRequest.swift in Sources */,
60206022
B3B41F132848E59D0017CA4B /* AnnotationsFilterViewController.swift in Sources */,

Zotero/Scenes/Detail/ItemDetail/Views/CollapsibleLabel.swift renamed to Zotero/Scenes/Detail/ItemDetail/Views/CollapsibleTextView.swift

Lines changed: 114 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,58 @@
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

99
import 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+
153255
fileprivate extension NSAttributedString {
154256
func lines(for width: CGFloat) -> [CTLine]? {
155257
let path = UIBezierPath(rect: CGRect(x: 0, y: 0, width: width, height: .greatestFiniteMagnitude))

Zotero/Scenes/Detail/ItemDetail/Views/ItemDetailAbstractCell.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ final class ItemDetailAbstractCell: UICollectionViewListCell {
1414
let isCollapsed: Bool
1515
let layoutMargins: UIEdgeInsets
1616
let maxWidth: CGFloat
17+
var toggleCollapse: (() -> Void)?
1718

1819
func makeContentView() -> UIView & UIContentView {
1920
return ContentView(configuration: self)
@@ -51,6 +52,7 @@ final class ItemDetailAbstractCell: UICollectionViewListCell {
5152
}
5253

5354
private func apply(configuration: ContentConfiguration) {
55+
self.contentView.toggleCollapse = configuration.toggleCollapse
5456
self.contentView.layoutMargins = configuration.layoutMargins
5557
self.contentView.setup(with: configuration.text, isCollapsed: configuration.isCollapsed, maxWidth: configuration.maxWidth)
5658
}

Zotero/Scenes/Detail/ItemDetail/Views/ItemDetailAbstractContentView.swift

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import UIKit
1111
class ItemDetailAbstractContentView: UIView {
1212
@IBOutlet private weak var titleTop: NSLayoutConstraint!
1313
@IBOutlet private weak var titleLabel: UILabel!
14-
@IBOutlet private weak var titleToContent: NSLayoutConstraint!
15-
@IBOutlet private weak var contentLabel: CollapsibleLabel!
14+
15+
private var contentTextView: CollapsibleTextView!
16+
private var titleToContent: NSLayoutConstraint!
17+
var toggleCollapse: (() -> Void)?
1618

1719
private static let paragraphStyle: NSMutableParagraphStyle = {
1820
let paragraphStyle = NSMutableParagraphStyle()
@@ -42,23 +44,61 @@ class ItemDetailAbstractContentView: UIView {
4244
self.titleLabel.font = titleFont
4345
self.titleTop.constant = ItemDetailLayout.separatorHeight - (titleFont.ascender - titleFont.capHeight)
4446

45-
let attributes: [NSAttributedString.Key: Any] = [.font: self.showMoreLessFont,
46-
.foregroundColor: Asset.Colors.zoteroBlue.color,
47-
.paragraphStyle: ItemDetailAbstractContentView.paragraphStyle]
48-
let showMore = NSMutableAttributedString(string: " ... ", attributes: [.font: self.bodyFont, .paragraphStyle: ItemDetailAbstractContentView.paragraphStyle])
49-
showMore.append(NSAttributedString(string: L10n.ItemDetail.showMore, attributes: attributes))
47+
setupContentTextView()
48+
}
49+
50+
private func setupContentTextView() {
51+
let textView = CollapsibleTextView()
52+
textView.translatesAutoresizingMaskIntoConstraints = false
53+
textView.adjustsFontForContentSizeCategory = true
54+
textView.setContentHuggingPriority(.init(1000), for: .horizontal)
55+
textView.setContentHuggingPriority(.init(750), for: .vertical)
56+
textView.setContentCompressionResistancePriority(.init(250), for: .horizontal)
57+
textView.setContentCompressionResistancePriority(.init(1000), for: .vertical)
58+
addSubview(textView)
59+
60+
titleToContent = textView.topAnchor.constraint(equalTo: titleLabel.lastBaselineAnchor, constant: 15)
61+
NSLayoutConstraint.activate([
62+
textView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
63+
textView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
64+
titleToContent,
65+
layoutMarginsGuide.bottomAnchor.constraint(equalTo: textView.bottomAnchor)
66+
])
67+
68+
textView.onToggle = { [weak self] in
69+
self?.toggleCollapse?()
70+
}
71+
72+
let attributes: [NSAttributedString.Key: Any] = [
73+
.font: showMoreLessFont,
74+
.foregroundColor: Asset.Colors.zoteroBlue.color,
75+
.paragraphStyle: ItemDetailAbstractContentView.paragraphStyle
76+
]
77+
78+
let showMore = NSMutableAttributedString(
79+
string: " ... ",
80+
attributes: [.font: bodyFont, .paragraphStyle: ItemDetailAbstractContentView.paragraphStyle]
81+
)
82+
let showMoreLink = NSMutableAttributedString(string: L10n.ItemDetail.showMore, attributes: attributes)
83+
showMoreLink.addAttribute(.link, value: CollapsibleTextView.toggleURL, range: NSRange(location: 0, length: showMoreLink.length))
84+
showMore.append(showMoreLink)
85+
86+
let showLessLink = NSMutableAttributedString(string: " \(L10n.ItemDetail.showLess)", attributes: attributes)
87+
showLessLink.addAttribute(.link, value: CollapsibleTextView.toggleURL, range: NSRange(location: 0, length: showLessLink.length))
88+
89+
textView.collapsedNumberOfLines = 2
90+
textView.showLessString = showLessLink
91+
textView.showMoreString = showMore
5092

51-
self.contentLabel.collapsedNumberOfLines = 2
52-
self.contentLabel.showLessString = NSAttributedString(string: " \(L10n.ItemDetail.showLess)", attributes: attributes)
53-
self.contentLabel.showMoreString = showMore
93+
contentTextView = textView
5494
}
5595

5696
func setup(with abstract: String, isCollapsed: Bool, maxWidth: CGFloat) {
5797
let font = self.bodyFont
5898
let attributes: [NSAttributedString.Key: Any] = [.paragraphStyle: ItemDetailAbstractContentView.paragraphStyle, .font: font]
5999
let hyphenatedText = NSAttributedString(string: abstract, attributes: attributes)
60100

61-
self.contentLabel.set(text: hyphenatedText, isCollapsed: isCollapsed, maxWidth: maxWidth)
101+
self.contentTextView.set(text: hyphenatedText, isCollapsed: isCollapsed, maxWidth: maxWidth)
62102

63103
let lineHeightOffset = (ItemDetailLayout.lineHeight - font.lineHeight)
64104
self.titleToContent.constant = ceil(self.layoutMargins.top - (font.ascender - font.capHeight) - lineHeightOffset)

0 commit comments

Comments
 (0)