├── .gitignore
├── Sources
└── Stories
│ ├── Extensions
│ ├── RandomAccessCollection+Index.swift
│ ├── Collection+SafeSubscript.swift
│ └── View+OnTapGesture.swift
│ ├── Models
│ └── ProgressTabContent.swift
│ └── Views
│ ├── ProgressTabView+View.swift
│ └── ProgressTabView.swift
├── Package.swift
├── LICENSE
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/Sources/Stories/Extensions/RandomAccessCollection+Index.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RandomAccessCollection+Index.swift
3 | //
4 | // Created by James Sedlacek on 11/17/24.
5 | //
6 |
7 | extension RandomAccessCollection where Element: Identifiable {
8 | /// Finds the index of an element with the given `id`.
9 | public func index(of id: Element.ID?) -> Index? {
10 | guard let id = id else { return nil }
11 | return firstIndex(where: { $0.id == id })
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.0
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "SwiftUI-Stories",
8 | defaultLocalization: "en",
9 | platforms: [.iOS(.v17), .macOS(.v14)],
10 | products: [
11 | .library(
12 | name: "Stories",
13 | targets: ["Stories"]),
14 | ],
15 | targets: [
16 | .target(
17 | name: "Stories"),
18 | ]
19 | )
20 |
--------------------------------------------------------------------------------
/Sources/Stories/Models/ProgressTabContent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProgressTabContent.swift
3 | //
4 | // Created by James Sedlacek on 2/2/25.
5 | //
6 |
7 | import SwiftUI
8 |
9 | /// A protocol that defines the requirements for content that can be displayed
10 | /// in a ProgressTabView with an associated progress tracking capability.
11 | ///
12 | /// Conforming types must provide:
13 | /// - A unique identifier through `Identifiable` conformance
14 | /// - A view hierarchy through `View` conformance
15 | /// - A `totalTime` value indicating how long the content should be displayed
16 | public protocol ProgressTabContent: View, Identifiable {
17 | /// The total time (in seconds) that this content should be displayed
18 | /// before automatically advancing to the next tab.
19 | var totalTime: TimeInterval { get }
20 | }
21 |
22 | /// Default implementation for ProgressTabContent
23 | public extension ProgressTabContent {
24 | /// Default implementation uses self as the identifier,
25 | /// ensuring each tab content instance is uniquely identifiable.
26 | var id: Self { self }
27 | }
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Sedlacek Solutions
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Sources/Stories/Extensions/Collection+SafeSubscript.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Collection+SafeSubscript.swift
3 | //
4 | //
5 | // Created by James Sedlacek on 2/24/24.
6 | //
7 |
8 | import Foundation
9 |
10 | extension Collection {
11 | /// Accesses the element at the specified position in a safe manner.
12 | ///
13 | /// This subscript provides a safe way to access elements within the collection by returning `nil` instead of crashing when accessing an index out of bounds.
14 | /// It is useful for avoiding runtime errors when the exact count of the collection might not be known or when dealing with dynamic data sources.
15 | /// This approach ensures that attempts to access indices outside the valid range simply result in `nil`, allowing for more graceful handling of such cases.
16 | ///
17 | /// - Parameter index: The position of the element to access. If the index is within the bounds of the collection, the element at that position is returned. Otherwise, `nil` is returned.
18 | /// - Returns: The element at the specified index if it is within the collection's bounds; otherwise, `nil`.
19 | ///
20 | /// Example usage:
21 | /// ```
22 | /// let numbers = [1, 2, 3, 4, 5]
23 | /// if let number = numbers[safe: 3] {
24 | /// print(number) // Prints "4"
25 | /// } else {
26 | /// print("Index out of bounds")
27 | /// }
28 | ///
29 | /// if let outOfBoundsNumber = numbers[safe: 10] {
30 | /// print(outOfBoundsNumber)
31 | /// } else {
32 | /// print("Index out of bounds") // Prints "Index out of bounds"
33 | /// }
34 | /// ```
35 | ///
36 | /// This method enhances the safety and readability of collection access, especially in more complex logic where bounds checks are necessary.
37 | subscript(safe index: Index) -> Element? {
38 | indices.contains(index) ? self[index] : nil
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Sources/Stories/Extensions/View+OnTapGesture.swift:
--------------------------------------------------------------------------------
1 | //
2 | // View+OnTapGesture.swift
3 | //
4 | // Created by James Sedlacek on 11/15/24.
5 | //
6 |
7 | import SwiftUI
8 |
9 | /// A view modifier that adds tap gesture recognition to specific horizontal regions of a view.
10 | /// This modifier splits the view into left and right halves and allows for different tap actions
11 | /// based on which side is tapped.
12 | @MainActor
13 | struct OnTapGestureAlignmentModifier: ViewModifier {
14 | /// The horizontal edge (leading or trailing) where the tap gesture should be active.
15 | let alignment: HorizontalEdge
16 | /// The action to perform when the specified region is tapped.
17 | let action: () -> Void
18 |
19 | /// Creates the modified view with a tap-sensitive region on the specified side.
20 | /// - Parameter content: The view being modified.
21 | /// - Returns: A view with the tap gesture overlay applied.
22 | func body(content: Content) -> some View {
23 | GeometryReader { geometry in
24 | content
25 | .overlay(alignment: alignment == .leading ? .leading : .trailing) {
26 | Color.clear
27 | .contentShape(.rect)
28 | .frame(width: geometry.size.width / 2)
29 | .onTapGesture(perform: action)
30 | }
31 | }
32 | }
33 | }
34 |
35 | /// Extension providing convenience modifiers for adding aligned tap gestures to views.
36 | @MainActor
37 | extension View {
38 | /// Adds a tap gesture to either the leading or trailing half of a view.
39 | /// - Parameters:
40 | /// - alignment: The horizontal edge (leading or trailing) where the tap gesture should be active.
41 | /// - action: The closure to execute when the specified region is tapped.
42 | /// - Returns: A view that responds to taps on the specified side.
43 | public func onTapGesture(
44 | alignment: HorizontalEdge,
45 | perform action: @escaping () -> Void
46 | ) -> some View {
47 | self.modifier(
48 | OnTapGestureAlignmentModifier(
49 | alignment: alignment,
50 | action: action
51 | )
52 | )
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SwiftUI-Stories
2 |
3 | A SwiftUI package that provides a custom tab view with progress tracking functionality.
4 | Each tab can have its own duration and automatically advances when completed.
5 |
6 | ## Features
7 |
8 | - 🔄 Automatic progress tracking for each tab
9 | - ⏱️ Customizable duration for each tab
10 | - 👆 Gesture-based navigation (tap left/right)
11 | - 📱 Swipe between tabs
12 | - ⏸️ Long press to pause progress
13 | - 📱 Full SwiftUI implementation
14 | - 🔄 Circular navigation (wraps around)
15 |
16 | ## Requirements
17 |
18 | - iOS 17.0+
19 | - Swift 6+
20 | - Xcode 16.0+
21 |
22 | ## Installation
23 |
24 | ### Swift Package Manager
25 |
26 | Add SwiftUI-Stories to your project through Xcode:
27 |
28 | 1. File > Add Packages
29 | 2. Enter package URL: ```https://github.com/Sedlacek-Solutions/ProgressTabView.git```
30 | 3. Select version requirements
31 | 4. Click Add Package
32 |
33 | ## Usage
34 |
35 | 1. First, create your tab content by conforming to `ProgressTabContent`:
36 |
37 | ```swift
38 | import Stories
39 | import SwiftUI
40 |
41 | enum ProgressTab: ProgressTabContent, CaseIterable {
42 | case first
43 | case second
44 | case third
45 |
46 | var totalTime: TimeInterval {
47 | switch self {
48 | case .first: 3
49 | case .second: 6
50 | case .third: 9
51 | }
52 | }
53 |
54 | var body: some View {
55 | Group {
56 | switch self {
57 | case .first: Text("1")
58 | case .second: Text("2")
59 | case .third: Text("3")
60 | }
61 | }
62 | .font(.largeTitle)
63 | }
64 | }
65 | ```
66 |
67 | 2. Then, use `ProgressTabView` in your SwiftUI view:
68 |
69 | ```swift
70 | import Stories
71 | import SwiftUI
72 |
73 | struct ContentView: View {
74 | var body: some View {
75 | ProgressTabView(tabs: ProgressTab.allCases)
76 | }
77 | }
78 | ```
79 |
80 | ## Interaction
81 |
82 | - Swipe left or right to navigate between tabs
83 | - Tap the right side of a tab to advance to the next tab
84 | - Tap the left side of a tab to go back to the previous tab
85 | - Long press to pause the progress timer
86 | - Release long press to resume the timer
87 |
88 | ## Customization
89 |
90 | You can customize the progress increment amount when initializing the view:
91 |
92 | ```swift
93 | ProgressTabView(
94 | tabs: tabs,
95 | incrementAmount: 0.05 // Updates every 0.05 seconds
96 | )
97 | ```
98 |
--------------------------------------------------------------------------------
/Sources/Stories/Views/ProgressTabView+View.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProgressTabView+View.swift
3 | //
4 | // Created by James Sedlacek on 11/17/24.
5 | //
6 |
7 | import SwiftUI
8 |
9 | /// View extension for ProgressTabView that implements the visual interface
10 | /// and interaction handling for the tab navigation system.
11 | extension ProgressTabView: View {
12 | /// The main body of the view that creates a horizontal scrollable container
13 | /// with progress indicators and interactive tabs.
14 | public var body: some View {
15 | ScrollView(.horizontal) {
16 | LazyHStack {
17 | ForEach(tabs, content: tabView)
18 | .containerRelativeFrame(.horizontal, count: 1, spacing: .zero)
19 | }
20 | .scrollTargetLayout()
21 | }
22 | .scrollTargetBehavior(.viewAligned)
23 | .scrollIndicators(.never)
24 | .scrollPosition(id: $selectedTabID)
25 | .onAppear(perform: startTimer)
26 | .onDisappear(perform: endTimer)
27 | .safeAreaInset(edge: .top, content: progressViews)
28 | .onChange(of: selectedTabID, resetProgress)
29 | }
30 |
31 | /// Creates an interactive view for an individual tab.
32 | /// - Parameter tab: The tab content to display.
33 | /// - Returns: A view with tap and long press gesture handlers.
34 | private func tabView(_ tab: TabContent) -> some View {
35 | tab
36 | .frame(
37 | maxWidth: .infinity,
38 | maxHeight: .infinity,
39 | alignment: .center
40 | )
41 | .onTapGesture(
42 | alignment: .leading,
43 | perform: decrementTab
44 | )
45 | .onTapGesture(
46 | alignment: .trailing,
47 | perform: incrementTab
48 | )
49 | .onLongPressGesture(
50 | perform: endTimer,
51 | onPressingChanged: onPressingChanged
52 | )
53 | }
54 |
55 | /// Creates the progress indicator views that appear above the tabs.
56 | /// - Returns: A horizontal stack of progress bars showing completion status for each tab.
57 | private func progressViews() -> some View {
58 | HStack(spacing: 8) {
59 | ForEach(tabs) { tab in
60 | ProgressView(
61 | value: progress(for: tab),
62 | total: tab.totalTime
63 | )
64 | }
65 | .tint(.primary)
66 | .animation(nil, value: selectedTabID)
67 | }
68 | .padding(.horizontal)
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/Sources/Stories/Views/ProgressTabView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProgressTabView.swift
3 | //
4 | // Created by James Sedlacek on 11/14/24.
5 | //
6 |
7 | import Combine
8 | import SwiftUI
9 |
10 | /// A view that displays a horizontally scrollable collection of tabs with progress indicators.
11 | /// Each tab implements the `ProgressTabContent` protocol and shows its progress in a bar above.
12 | @MainActor
13 | public struct ProgressTabView {
14 | /// The collection of tabs to display.
15 | let tabs: [TabContent]
16 | /// The amount to increment progress by each timer tick.
17 | private let incrementAmount: TimeInterval
18 |
19 | /// The ID of the currently selected tab.
20 | @State private(set) var selectedTabID: TabContent.ID?
21 | /// The current progress of the selected tab (in seconds).
22 | @State private var currentProgress: TimeInterval = 0
23 |
24 | /// Timer publisher for auto-advancing progress.
25 | @State private var timer: Timer.TimerPublisher?
26 | /// Cancellable object for the timer subscription.
27 | @State private var timerCancellable: Cancellable?
28 |
29 | /// Creates a new ProgressTabView with the specified tabs and increment amount.
30 | /// - Parameters:
31 | /// - tabs: An array of views conforming to `ProgressTabContent`. Each tab will be displayed
32 | /// in a horizontally scrollable container with a progress indicator above it.
33 | /// - incrementAmount: The time interval (in seconds) by which the progress should increment
34 | /// on each timer tick. Defaults to 0.01 seconds (100 ticks per second).
35 | /// Smaller values result in smoother progress updates but more frequent timer events.
36 | public init(
37 | tabs: [TabContent],
38 | incrementAmount: TimeInterval = 0.01
39 | ) {
40 | self.tabs = tabs
41 | self.incrementAmount = incrementAmount
42 | if let firstTabID = tabs.first?.id {
43 | self._selectedTabID = .init(initialValue: firstTabID)
44 | }
45 | }
46 |
47 | /// Resets the progress of the current tab to zero.
48 | func resetProgress() {
49 | currentProgress = 0
50 | }
51 |
52 | /// Calculates the progress for a specific tab.
53 | /// - Parameter tab: The tab to calculate progress for.
54 | /// - Returns: The progress value in seconds.
55 | /// - For the selected tab: returns the current progress
56 | /// - For tabs before the selected tab: returns their total time (completed)
57 | /// - For tabs after the selected tab: returns zero (not started)
58 | func progress(for tab: TabContent) -> TimeInterval {
59 | guard let selectedID = selectedTabID,
60 | let selectedIndex = tabs.index(of: selectedID) else {
61 | return 0
62 | }
63 |
64 | guard let tabIndex = tabs.index(of: tab.id) else {
65 | return 0
66 | }
67 |
68 | if tab.id == selectedID {
69 | return currentProgress
70 | } else if tabIndex < selectedIndex {
71 | // If the tab is before the selected tab, return its total time (completed)
72 | return tab.totalTime
73 | } else {
74 | // If the tab is after the selected tab, return zero (not started)
75 | return 0
76 | }
77 | }
78 |
79 | /// Starts the timer that automatically advances progress.
80 | func startTimer() {
81 | guard timer == nil else { return }
82 |
83 | timer = Timer.publish(
84 | every: incrementAmount,
85 | on: .main,
86 | in: .common
87 | )
88 |
89 | timerCancellable = timer?.autoconnect().sink { _ in
90 | incrementProgress()
91 | }
92 | }
93 |
94 | /// Stops the timer and cleans up resources.
95 | func endTimer() {
96 | timerCancellable?.cancel()
97 | timerCancellable = nil
98 | timer = nil
99 | }
100 |
101 | /// Increments the progress of the current tab.
102 | /// When the progress reaches the tab's total time, it automatically advances to the next tab.
103 | func incrementProgress() {
104 | guard let tabID = selectedTabID,
105 | let tabIndex = tabs.index(of: tabID),
106 | let tab = tabs[safe: tabIndex] else { return }
107 |
108 | if currentProgress < tab.totalTime {
109 | currentProgress += incrementAmount
110 | } else {
111 | incrementTab()
112 | }
113 | }
114 |
115 | /// Handles long press state changes on tabs.
116 | /// - Parameter isPressed: Whether the tab is currently being pressed.
117 | func onPressingChanged(_ isPressed: Bool) {
118 | if isPressed {
119 | endTimer()
120 | } else {
121 | startTimer()
122 | }
123 | }
124 |
125 | /// Advances to the next tab in the sequence.
126 | /// If at the last tab, wraps around to the first tab.
127 | func incrementTab() {
128 | guard let selectedTabID, let selectedIndex = tabs.index(of: selectedTabID) else {
129 | selectedTabID = tabs.first?.id
130 | return
131 | }
132 |
133 | withAnimation {
134 | if selectedIndex < tabs.count - 1 {
135 | self.selectedTabID = tabs[selectedIndex + 1].id
136 | } else {
137 | self.selectedTabID = tabs[0].id
138 | }
139 | }
140 |
141 | resetProgress()
142 | }
143 |
144 | /// Moves to the previous tab in the sequence.
145 | /// If at the first tab, stays on the first tab.
146 | func decrementTab() {
147 | guard let selectedTabID, let selectedIndex = tabs.index(of: selectedTabID) else {
148 | selectedTabID = tabs.first?.id
149 | return
150 | }
151 |
152 | if selectedIndex > 0 {
153 | self.selectedTabID = tabs[selectedIndex - 1].id
154 | }
155 |
156 | resetProgress()
157 | }
158 | }
159 |
--------------------------------------------------------------------------------