Skip to content

Instantly share code, notes, and snippets.

@auramagi
Last active June 25, 2024 23:06
Show Gist options
  • Save auramagi/cfa3d3af5ae8fec899579d6d4c42f3dc to your computer and use it in GitHub Desktop.
Save auramagi/cfa3d3af5ae8fec899579d6d4c42f3dc to your computer and use it in GitHub Desktop.
SwiftUI read more component. Requires iOS 16.
import SwiftUI
struct ContentView: View {
var text = "long text"
@State private var lines = 3.0
var body: some View {
ScrollView {
ReadMore(lineLimit: Int(lines)) {
Text(text)
}
.padding()
}
.safeAreaInset(edge: .bottom, spacing: 0) {
VStack {
GroupBox {
Slider(value: $lines, in: 1...24, step: 1)
} label: {
LabeledContent {
Text(Int(lines), format: .number)
} label: {
Text("Lines")
}
}
}
.padding(.horizontal)
}
}
}
struct ReadMore<Content: View>: View {
let lineLimit: Int
@ViewBuilder var content: Content
@State private var isExpanded: Bool = false
var body: some View {
ReadMoreLayout {
ViewThatFits(in: .vertical) {
if !isExpanded {
fullView
}
shortView
}
}
}
var fullView: some View {
content
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
var shortView: some View {
VStack(alignment: .leading) {
ZStack {
content
.lineLimit(isExpanded ? nil : max(1, lineLimit))
.contentTransition(.opacity)
.frame(maxWidth: .infinity, alignment: .leading)
}
.onTapGesture {
withAnimation {
isExpanded.toggle()
}
}
.clipped()
Button {
withAnimation {
isExpanded.toggle()
}
} label: {
HStack(spacing: 2) {
withSymbolTransition {
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.imageScale(.small)
.symbolVariant(.circle.fill)
.symbolRenderingMode(.hierarchical)
}
.backportGeometryGroup()
Text(isExpanded ? "Less" : "More")
}
}
.backportGeometryGroup()
}
.fixedSize(horizontal: false, vertical: true)
}
@ViewBuilder func withSymbolTransition(@ViewBuilder image: () -> some View) -> some View {
if #available(iOS 17.0, *) {
image()
.contentTransition(.symbolEffect(.replace.wholeSymbol.offUp))
} else {
image()
}
}
}
private struct ReadMoreLayout: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
guard let view = subviews.first else {
return proposal.replacingUnspecifiedDimensions()
}
let expanded = view.sizeThatFits(
.init(
width: proposal.replacingUnspecifiedDimensions().width,
height: nil
)
)
let collapsed = view.sizeThatFits(
.init(
width: proposal.replacingUnspecifiedDimensions().width,
height: .zero
)
)
if expanded.height <= collapsed.height {
return expanded
} else {
return collapsed
}
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
let size = sizeThatFits(proposal: proposal, subviews: subviews, cache: &cache)
subviews.first?.place(
at: .init(x: bounds.midX, y: bounds.midY),
anchor: .center,
proposal: .init(width: size.width, height: size.height)
)
}
}
extension View {
@ViewBuilder public func backportGeometryGroup() -> some View {
if #available(iOS 17, *) {
geometryGroup()
} else {
transformEffect(.identity)
}
}
}
#Preview {
ContentView()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment