Last active
June 25, 2024 23:06
-
-
Save auramagi/cfa3d3af5ae8fec899579d6d4c42f3dc to your computer and use it in GitHub Desktop.
SwiftUI read more component. Requires iOS 16.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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