1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import SwiftUI
import Translation
struct ExpandableDescriptionView: View {
let text: String
var lineLimit: Int = 2
var onMentionTap: ((String) -> Void)?
var onHashtagTap: ((String) -> Void)?
@State private var isExpanded = false
@State private var isTruncated = false
@State private var showTranslation = false
@State private var isForeignLanguage = false
var body: some View {
VStack(alignment: .leading, spacing: 2) {
RichTextView(
text: text,
color: .secondary,
onMentionTap: onMentionTap,
onHashtagTap: onHashtagTap
)
.lineLimit(isExpanded ? nil : lineLimit)
.background(
RichTextView(
text: text,
color: .clear,
onMentionTap: nil,
onHashtagTap: nil
)
.lineLimit(lineLimit)
.hidden()
.background(GeometryReader { truncatedGeo in
RichTextView(
text: text,
color: .clear,
onMentionTap: nil,
onHashtagTap: nil
)
.fixedSize(horizontal: false, vertical: true)
.hidden()
.background(GeometryReader { fullGeo in
Color.clear.onAppear {
isTruncated = fullGeo.size.height > truncatedGeo.size.height + 1
}
})
})
)
.contentShape(Rectangle())
.onTapGesture {
if isTruncated, !isExpanded {
withAnimation(.easeInOut(duration: 0.2)) {
isExpanded = true
}
}
}
HStack(spacing: 12) {
if isTruncated, !isExpanded {
Text("more")
.font(.subheadline)
.foregroundStyle(.tertiary)
}
if isForeignLanguage {
Button {
showTranslation = true
} label: {
Text("translate")
.font(.subheadline)
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
}
}
}
.translationPresentation(isPresented: $showTranslation, text: text)
// `.task` rather than `.onAppear`: detection is off the main actor now,
// so it must not block the card appearing. Cancelling on disappear is
// free — a card scrolled past before detection finishes just re-runs on
// re-entry and hits the cache.
.task {
isForeignLanguage = await LanguageDetector.shared.isForeign(text)
}
}
}
#Preview {
VStack(alignment: .leading, spacing: 20) {
ExpandableDescriptionView(text: "Short caption.")
ExpandableDescriptionView(
text: "A much longer caption that keeps going well past two lines. Photographed at golden hour in the hills above the city. Shot with #35mm film, developed in Rodinal. See @alice.grain.social for the full series.",
onMentionTap: { _ in },
onHashtagTap: { _ in }
)
}
.padding()
.preferredColorScheme(.dark)
.tint(Color.accentColor)
}