Stories Viewer
A full-screen story pager with segmented progress bars, tap to move and hold to pause.

Description
Section titled “Description”A full-screen story pager with segmented progress bars.
Tap the left or right half to move, and hold to pause. Stories do not advance by themselves with Reduce Motion or VoiceOver on: the tap areas still work.
struct StoriesViewerDemo: View { @State private var run = 0
private let stories = [ StoriesViewerStory( id: "dawn", image: placeholder(colors: [.orange, .pink, .purple], symbol: "sunrise.fill"), title: "Morning hike", caption: "First light over the ridge.", imageLabel: "A sunrise on a pink and purple sky" ), StoriesViewerStory( id: "forest", image: placeholder(colors: [.teal, .green, .black], symbol: "tree.fill"), title: "Into the woods", caption: "Three hours of pure green.", imageLabel: "A tree on a dark green background" ), StoriesViewerStory( id: "night", image: placeholder(colors: [.indigo, .blue, .black], symbol: "moon.stars.fill"), title: "Camp", caption: "And then the stars came out.", imageLabel: "A moon and stars on a night sky" ), ]
var body: some View { StoriesViewer(stories: stories, duration: 3, onFinish: { run += 1 }) .id(run) }
private static func placeholder(colors: [Color], symbol: String) -> Image { let size = CGSize(width: 402, height: 874) return Image(size: size, opaque: true) { context in context.fill( Path(CGRect(origin: .zero, size: size)), with: .linearGradient(Gradient(colors: colors), startPoint: .zero, endPoint: CGPoint(x: size.width, y: size.height)) )
var icon = context.resolve(Image(systemName: symbol)) icon.shading = .color(.white.opacity(0.35)) let scale = 200 / max(icon.size.width, icon.size.height, 1) let iconSize = CGSize(width: icon.size.width * scale, height: icon.size.height * scale) context.draw(icon, in: CGRect( x: (size.width - iconSize.width) / 2, y: (size.height - iconSize.height) / 2, width: iconSize.width, height: iconSize.height )) } }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
stories | [StoriesViewerStory] | Required |
duration | TimeInterval | 4 |
onClose | (() -> Void)? | nil |
onFinish | () -> Void | {} |
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
struct StoriesViewerStory: Identifiable { let id: String let image: Image var title: String var caption: String /// What the image shows, for VoiceOver. Leave it nil when the image is decoration. var imageLabel: String?}
/// A full-screen story pager with segmented progress bars.////// Tap the left or right half to move, and hold to pause. Stories do not advance by themselves/// with Reduce Motion or VoiceOver on: the tap areas still work.struct StoriesViewer: View { let stories: [StoriesViewerStory] var duration: TimeInterval = 4 var onClose: (() -> Void)? var onFinish: () -> Void = {}
@State private var playback = StoriesViewerPlayback() @State private var isPressing = false @State private var pressStart: Date?
@Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityVoiceOverEnabled) private var voiceOverEnabled
private let holdThreshold: TimeInterval = 0.25
private var advancesByItself: Bool { !reduceMotion && !voiceOverEnabled }
private var isRunning: Bool { advancesByItself && !isPressing && !playback.isFinished && !stories.isEmpty }
private var current: StoriesViewerStory? { stories.indices.contains(playback.index) ? stories[playback.index] : stories.last }
var body: some View { ZStack { Color.black.ignoresSafeArea()
if let current { Color.clear .overlay { storyImage(current) } .clipped() .ignoresSafeArea() .id(current.id) .transition(.opacity)
tapAreas
VStack(spacing: 0) { header(for: current) Spacer(minLength: 24) caption(for: current) } .opacity(isPressing ? 0 : 1) .animation(.easeInOut(duration: 0.2).delay(isPressing ? holdThreshold : 0), value: isPressing) } } .foregroundStyle(.white) .task(id: StoriesViewerSchedule(isRunning: isRunning, count: stories.count, duration: duration)) { guard isRunning else { return } var last = Date.now while !Task.isCancelled { try? await Task.sleep(for: .milliseconds(33)) guard !Task.isCancelled else { return } let now = Date.now let elapsed = now.timeIntervalSince(last) last = now update { $0.tick(elapsed: elapsed, duration: duration, count: stories.count) } } } .onChange(of: stories.count) { _, count in playback.clamp(count: count) } .onChange(of: playback.isFinished) { _, isFinished in if isFinished { onFinish() } } }
@ViewBuilder private func storyImage(_ story: StoriesViewerStory) -> some View { let image = story.image.resizable().scaledToFill()
if let label = story.imageLabel { image.accessibilityLabel(label) } else { image.accessibilityRepresentation { EmptyView() } } }
private var tapAreas: some View { HStack(spacing: 0) { tapArea("Previous story") { update { $0.previous(count: stories.count) } } tapArea("Next story") { update { $0.next(count: stories.count) } } } }
private func tapArea(_ label: String, action: @escaping () -> Void) -> some View { Button { let wasHold = pressStart.map { Date.now.timeIntervalSince($0) >= holdThreshold } ?? false pressStart = nil if !wasHold { action() } } label: { Color.clear.frame(maxWidth: .infinity, maxHeight: .infinity) } .buttonStyle(StoriesViewerTapAreaStyle { isPressed in if isPressed { pressStart = .now } isPressing = isPressed }) .accessibilityLabel(label) }
private func header(for story: StoriesViewerStory) -> some View { VStack(spacing: 10) { HStack(spacing: 4) { ForEach(stories.indices, id: \.self) { segment in Capsule() .fill(.white.opacity(0.32)) .overlay { Capsule() .fill(.white) .scaleEffect(x: fill(for: segment), anchor: .leading) } .frame(height: 3) } } .allowsHitTesting(false) .accessibilityElement(children: .ignore) .accessibilityLabel("Story") .accessibilityValue("\(min(playback.index + 1, stories.count)) of \(stories.count)")
HStack { Text(story.title) .font(.headline) .lineLimit(2) .minimumScaleFactor(0.7) .allowsHitTesting(false) Spacer() if let onClose { Button(action: onClose) { Image(systemName: "xmark") .font(.headline) .frame(minWidth: 44, minHeight: 44) .contentShape(.rect) } .accessibilityLabel("Close") } } } .padding(.horizontal, 12) .padding(.top, 8) }
private func caption(for story: StoriesViewerStory) -> some View { Text(story.caption) .font(.title3.weight(.semibold)) .lineLimit(8) .minimumScaleFactor(0.6) .shadow(color: .black.opacity(0.45), radius: 6, y: 2) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 20) .padding(.bottom, 24) .allowsHitTesting(false) }
private func fill(for segment: Int) -> Double { if !advancesByItself, segment == playback.index { return 1 } return playback.fill(for: segment) }
private func update(_ change: (inout StoriesViewerPlayback) -> Void) { var updated = playback change(&updated) if updated.index == playback.index { playback = updated } else { withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.2)) { playback = updated } } }}
private struct StoriesViewerSchedule: Equatable { let isRunning: Bool let count: Int let duration: TimeInterval}
/// Reports touches, including ones the system cancels, so a hold can never get stuck.private struct StoriesViewerTapAreaStyle: ButtonStyle { let onPressChange: (Bool) -> Void
func makeBody(configuration: Configuration) -> some View { configuration.label .contentShape(.rect) .onChange(of: configuration.isPressed) { _, isPressed in onPressChange(isPressed) } }}
struct StoriesViewerPlayback: Equatable { private(set) var index = 0 private(set) var progress = 0.0 private(set) var isFinished = false
mutating func tick(elapsed: TimeInterval, duration: TimeInterval, count: Int) { guard count > 0, !isFinished, elapsed > 0 else { return } progress += elapsed / max(duration, 0.1) if progress >= 1 { next(count: count) } }
mutating func next(count: Int) { guard count > 0, !isFinished else { return } if index + 1 < count { index += 1 progress = 0 } else { progress = 1 isFinished = true } }
mutating func previous(count: Int) { guard count > 0 else { return } isFinished = false index = max(index - 1, 0) progress = 0 }
/// Keeps the position valid when the list of stories changes, and moves on if new ones arrive after the end. mutating func clamp(count: Int) { if isFinished, index + 1 < count { isFinished = false index += 1 progress = 0 return } guard index >= count else { return } index = max(count - 1, 0) progress = 0 }
/// How full a segment's bar is, from 0 to 1. func fill(for segment: Int) -> Double { if segment < index { return 1 } return segment == index ? min(max(progress, 0), 1) : 0 }}
// MARK: - Demo
struct StoriesViewerDemo: View { @State private var run = 0
private let stories = [ StoriesViewerStory( id: "dawn", image: placeholder(colors: [.orange, .pink, .purple], symbol: "sunrise.fill"), title: "Morning hike", caption: "First light over the ridge.", imageLabel: "A sunrise on a pink and purple sky" ), StoriesViewerStory( id: "forest", image: placeholder(colors: [.teal, .green, .black], symbol: "tree.fill"), title: "Into the woods", caption: "Three hours of pure green.", imageLabel: "A tree on a dark green background" ), StoriesViewerStory( id: "night", image: placeholder(colors: [.indigo, .blue, .black], symbol: "moon.stars.fill"), title: "Camp", caption: "And then the stars came out.", imageLabel: "A moon and stars on a night sky" ), ]
var body: some View { StoriesViewer(stories: stories, duration: 3, onFinish: { run += 1 }) .id(run) }
private static func placeholder(colors: [Color], symbol: String) -> Image { let size = CGSize(width: 402, height: 874) return Image(size: size, opaque: true) { context in context.fill( Path(CGRect(origin: .zero, size: size)), with: .linearGradient(Gradient(colors: colors), startPoint: .zero, endPoint: CGPoint(x: size.width, y: size.height)) )
var icon = context.resolve(Image(systemName: symbol)) icon.shading = .color(.white.opacity(0.35)) let scale = 200 / max(icon.size.width, icon.size.height, 1) let iconSize = CGSize(width: icon.size.width * scale, height: icon.size.height * scale) context.draw(icon, in: CGRect( x: (size.width - iconSize.width) / 2, y: (size.height - iconSize.height) / 2, width: iconSize.width, height: iconSize.height )) } }}
#Preview { StoriesViewerDemo()}