├── .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 | --------------------------------------------------------------------------------