Read More Text
A paragraph truncated with a fade that expands in place when tapped.

- Text
- iOS 18.0+
- beginner
- Origin
- Original work
- Found by
- read more · expandable text · truncate · show more · fade · paragraph · leer más · texto expandible · truncar · mostrar más · difuminado · párrafo
Description
Section titled “Description”A paragraph truncated with a fade that expands in place when tapped.
The button only appears when the text does not fit. With Reduce Motion on, the paragraph changes height at once. VoiceOver always reads the whole paragraph.
struct ReadMoreTextDemo: View { private let story = "The sea looks blue because water absorbs the red end of the spectrum and scatters the blue back towards your eyes. The deeper the water, the more red is lost, which is why the open ocean looks so much darker than a shallow bay. Near the coast, sand and algae add greens and browns, and a bright sky adds its own reflection to the surface."
var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { RoundedRectangle(cornerRadius: 24) .fill(LinearGradient(colors: [.cyan, .blue, .indigo], startPoint: .topLeading, endPoint: .bottomTrailing)) .frame(height: 220) .overlay { Image(systemName: "water.waves") .font(.largeTitle) .imageScale(.large) .foregroundStyle(.white) } .accessibilityHidden(true)
Text("Why the sea is blue") .font(.title.bold())
ReadMoreText(text: story) .font(.body) .foregroundStyle(.secondary) } .padding(20) } }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
text | String | Required |
collapsedLines | Int | 3 |
moreLabel | String | "Read more" |
lessLabel | String | "Show less" |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// A paragraph truncated with a fade that expands in place when tapped.////// The button only appears when the text does not fit. With Reduce Motion on, the/// paragraph changes height at once. VoiceOver always reads the whole paragraph.struct ReadMoreText: View { let text: String var collapsedLines = 3 var moreLabel = "Read more" var lessLabel = "Show less"
@State private var isExpanded = false @State private var fullHeight: CGFloat = 0 @State private var collapsedHeight: CGFloat = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion
private let touchPadding: CGFloat = 13
private var isTruncated: Bool { fullHeight > collapsedHeight + 1 && collapsedHeight > 0 } private var isFaded: Bool { isTruncated && !isExpanded }
var body: some View { VStack(alignment: .leading, spacing: 10) { Text(text) .fixedSize(horizontal: false, vertical: true) .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { fullHeight = $0 } .background { collapsedMeasure } .frame(height: isTruncated ? (isExpanded ? fullHeight : collapsedHeight) : nil, alignment: .top) .clipped() .mask { fade }
if isTruncated { Button { isExpanded.toggle() } label: { // A line of text is too short to hit. The padding widens what answers to a // touch, and the same padding taken back keeps the layout as it was. Text(isExpanded ? lessLabel : moreLabel) .padding(.vertical, touchPadding) .contentShape(.rect) .padding(.vertical, -touchPadding) } .font(.subheadline.weight(.semibold)) .foregroundStyle(.tint) } } .animation(reduceMotion ? nil : .smooth(duration: 0.4), value: isExpanded) }
// A hidden copy, limited to the collapsed lines, tells how tall the collapsed text is. private var collapsedMeasure: some View { Text(text) .lineLimit(max(collapsedLines, 1)) .fixedSize(horizontal: false, vertical: true) .hidden() .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { collapsedHeight = $0 } .accessibilityRepresentation { EmptyView() } }
private var fade: LinearGradient { LinearGradient( stops: [ .init(color: .black, location: 0), .init(color: .black, location: isFaded ? 0.55 : 1), .init(color: isFaded ? .clear : .black, location: 1), ], startPoint: .top, endPoint: .bottom ) }}
// MARK: - Demo
struct ReadMoreTextDemo: View { private let story = "The sea looks blue because water absorbs the red end of the spectrum and scatters the blue back towards your eyes. The deeper the water, the more red is lost, which is why the open ocean looks so much darker than a shallow bay. Near the coast, sand and algae add greens and browns, and a bright sky adds its own reflection to the surface."
var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { RoundedRectangle(cornerRadius: 24) .fill(LinearGradient(colors: [.cyan, .blue, .indigo], startPoint: .topLeading, endPoint: .bottomTrailing)) .frame(height: 220) .overlay { Image(systemName: "water.waves") .font(.largeTitle) .imageScale(.large) .foregroundStyle(.white) } .accessibilityHidden(true)
Text("Why the sea is blue") .font(.title.bold())
ReadMoreText(text: story) .font(.body) .foregroundStyle(.secondary) } .padding(20) } }}
#Preview { ReadMoreTextDemo()}