├── .gitattributes ├── img ├── img_01.gif ├── img_03.png ├── img_08.gif ├── localization.png ├── stories_state.png └── errors_handling.png ├── .spi.yml ├── Sources └── d3-stories-instagram │ ├── Resources │ ├── en.lproj │ │ └── Localizable.strings │ └── es.lproj │ │ └── Localizable.strings │ ├── enum │ ├── Strategy.swift │ ├── StoriesState.swift │ └── StoriesInternalError.swift │ ├── protocol │ ├── IStoryTpl.swift │ ├── IStoriesValidater.swift │ ├── IStoriesError.swift │ ├── IStoriesManager.swift │ └── IStory.swift │ ├── key │ └── StoriesStateKey.swift │ ├── data │ ├── StoriesError.swift │ └── ProgressBarConfig.swift │ ├── example │ ├── CustomStoriesValidater.swift │ ├── Stories.swift │ └── StoryTpl.swift │ ├── ProgressBar.swift │ ├── viewmodel │ ├── StateManager.swift │ └── StoriesManager.swift │ ├── StoriesWidget.swift │ └── StoriesView.swift ├── Tests └── d3-stories-instagramTests │ └── d3_stories_instagramTests.swift ├── LICENSE ├── Package.swift ├── .gitignore └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /img/img_01.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swiftuiux/d3-stories-instagram/HEAD/img/img_01.gif -------------------------------------------------------------------------------- /img/img_03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swiftuiux/d3-stories-instagram/HEAD/img/img_03.png -------------------------------------------------------------------------------- /img/img_08.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swiftuiux/d3-stories-instagram/HEAD/img/img_08.gif -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [d3-stories-instagram] 5 | -------------------------------------------------------------------------------- /img/localization.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swiftuiux/d3-stories-instagram/HEAD/img/localization.png -------------------------------------------------------------------------------- /img/stories_state.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swiftuiux/d3-stories-instagram/HEAD/img/stories_state.png -------------------------------------------------------------------------------- /img/errors_handling.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swiftuiux/d3-stories-instagram/HEAD/img/errors_handling.png -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/Resources/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "empty_stories"="There are no stories"; 2 | "duration_error"="Duration must be a positive number greater than zero"; 3 | "errors_title"="Data validation errors"; 4 | 5 | -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/Resources/es.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "empty_stories"="las historias están vacías"; 2 | "duration_error"="La duración debe ser un número positivo mayor que cero"; 3 | "errors_title"="Errores de validación de datos"; 4 | -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/enum/Strategy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Strategy.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 23.06.2022. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Strategy for showing stories 11 | @available(iOS 15.0, macOS 12.0, tvOS 16.0, watchOS 10.0, *) 12 | public enum Strategy { 13 | /// Repeat stories 14 | case circle 15 | 16 | /// Show just once 17 | case once 18 | } 19 | -------------------------------------------------------------------------------- /Tests/d3-stories-instagramTests/d3_stories_instagramTests.swift: -------------------------------------------------------------------------------- 1 | @testable import d3_stories_instagram 2 | import XCTest 3 | 4 | final class d3_stories_instagramTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | // XCTAssertEqual(d3_stories_instagram().text, "Hello, World!") 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/protocol/IStoryTpl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IStoryTpl.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 28.06.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Template view for a story 11 | @available(iOS 15.0, macOS 12.0, tvOS 16.0, watchOS 10.0, *) 12 | public protocol IStoryTpl: View { 13 | associatedtype StoryType: IStory 14 | 15 | /// Current progress of showing story 16 | var progress: CGFloat { get } 17 | 18 | var story: StoryType { get } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/key/StoriesStateKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoriesStateKey.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 03.07.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Emerging stories state for ``StoriesWidget`` 11 | struct StoriesStateKey: PreferenceKey { 12 | typealias Value = StoriesState 13 | 14 | static var defaultValue: StoriesState = .ready 15 | 16 | static func reduce(value: inout Value, nextValue: () -> Value) { 17 | value = nextValue() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/protocol/IStoriesValidater.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IStoriesValidater.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 05.07.2022. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Interface to validate input stories data for ``StoriesWidget`` 11 | @available(iOS 15.0, macOS 12.0, tvOS 16.0, watchOS 10.0, *) 12 | public protocol IStoriesValidater { 13 | /// Check stories data 14 | /// - Parameter stories: Set of stories data 15 | /// - Returns: Errors 16 | static func validate(_ stories: [T]) -> [StoriesError] 17 | } 18 | -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/data/StoriesError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoriesError.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 05.07.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Description for errors found while validating ``IStory`` data set 11 | @available(iOS 15.0, macOS 12.0, tvOS 16.0, watchOS 10.0, *) 12 | public struct StoriesError: IStoriesError { 13 | public let description: LocalizedStringKey 14 | } 15 | 16 | public extension StoriesError { 17 | func hash(into hasher: inout Hasher) { 18 | hasher.combine("\(description)") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/data/ProgressBarConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProgressBarConfig.swift 3 | // 4 | // 5 | // Created by Isaac Iniongun on 01/03/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct ProgressBarConfig { 11 | let height: CGFloat 12 | let spacing: CGFloat 13 | let activeColor: Color 14 | let inactiveColor: Color 15 | 16 | public init( 17 | height: CGFloat = 2, 18 | spacing: CGFloat = 5, 19 | activeColor: Color = .primary, 20 | inactiveColor: Color = .primary.opacity(0.5) 21 | ) { 22 | self.height = height 23 | self.spacing = spacing 24 | self.activeColor = activeColor 25 | self.inactiveColor = inactiveColor 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/enum/StoriesState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoriesState.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 24.06.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Stories view states Inner data for managing stories view life circle 11 | @available(iOS 15.0, macOS 12.0, tvOS 16.0, watchOS 10.0, *) 12 | public enum StoriesState: Equatable { 13 | /// Waiting to start If there's leeway this is the state during this delay before the big start 14 | case ready 15 | /// Start of first stories, start of big circle 16 | case start 17 | /// Begin 18 | case begin 19 | /// Pause showing story 20 | case suspend(CGFloat) 21 | /// Resume showing story 22 | case resume(CGFloat) 23 | /// End of a story 24 | case end 25 | /// End of all stories big circle 26 | case finish 27 | } 28 | -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/example/CustomStoriesValidater.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomStoriesValidater.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 05.07.2022. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Custom validator for stories 11 | @available(iOS 15.0, macOS 12.0, tvOS 16.0, watchOS 10.0, *) 12 | public struct CustomStoriesValidater: IStoriesValidater { 13 | /// Check stories 14 | /// - Parameter stories: Set of stories 15 | /// - Returns: Set of errors found while checking stories set 16 | public static func validate(_ stories: [T]) -> [StoriesError] where T: IStory { 17 | var errors: [StoriesError] = [] 18 | 19 | if let first = stories.first, first.duration < 0.5 { 20 | errors.append(.init(description: "The first story less than five seconds")) 21 | } 22 | 23 | return errors 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Igor Shelopaev 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/enum/StoriesInternalError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoriesError.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 04.07.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Set of errors for input data validation 11 | enum StoriesInternalError: String, IStoriesValidater, IStoriesError { 12 | case empty = "empty_stories" 13 | 14 | case duration = "duration_error" 15 | 16 | var id: String { 17 | rawValue 18 | } 19 | 20 | /// Validate input data 21 | /// - Returns: ``StoriesError`` 22 | static func validate(_ stories: [T]) -> [StoriesError] where T: IStory { 23 | var errors = [StoriesError]() 24 | 25 | if stories.isEmpty { 26 | let e = empty 27 | errors.append(.init(description: e.description)) 28 | } 29 | 30 | if !stories.allSatisfy({ $0.duration > 0 }) { 31 | let e = duration 32 | errors.append(.init(description: e.description)) 33 | } 34 | 35 | return errors 36 | } 37 | 38 | /// Description for Tpl builder 39 | var description: LocalizedStringKey { 40 | LocalizedStringKey(rawValue) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/protocol/IStoriesError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IStoriesError.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 05.07.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Define interface for errors related to validation of data set ``IStory`` 11 | @available(iOS 15.0, macOS 12.0, tvOS 16.0, watchOS 10.0, *) 12 | public protocol IStoriesError: Error, Hashable { 13 | var description: LocalizedStringKey { get } 14 | } 15 | 16 | extension IStoriesError { 17 | /// Tpl for demonstrating an error 18 | @ViewBuilder 19 | static func builder(_ errors: [Self]) -> some View { 20 | ScrollView { 21 | VStack(alignment: .leading) { 22 | HStack { 23 | Text("errors_title", bundle: .module).multilineTextAlignment(.center) 24 | .foregroundColor(.primary) 25 | } 26 | .frame(maxWidth: .infinity) 27 | .font(.system(.title)) 28 | ForEach(errors, id: \.self) { e in 29 | Text(e.description, bundle: .module).padding(.top, 2) 30 | } 31 | }.padding() 32 | }.padding() 33 | .background(.thickMaterial) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 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: "d3-stories-instagram", 8 | defaultLocalization: "en", 9 | platforms: [ 10 | .macOS("12"), .iOS("15"), .tvOS("16"), .watchOS("10"), 11 | ], 12 | products: [ 13 | // Products define the executables and libraries a package produces, and make them visible to other packages. 14 | .library( 15 | name: "d3-stories-instagram", 16 | targets: ["d3-stories-instagram"] 17 | ), 18 | ], 19 | dependencies: [ 20 | // Dependencies declare other packages that this package depends on. 21 | // .package(url: /* package url */, from: "1.0.0"), 22 | ], 23 | targets: [ 24 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 25 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 26 | .target( 27 | name: "d3-stories-instagram", 28 | dependencies: [] 29 | ), 30 | .testTarget( 31 | name: "d3-stories-instagramTests", 32 | dependencies: ["d3-stories-instagram"] 33 | ), 34 | ] 35 | ) 36 | -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/example/Stories.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Stories.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 23.06.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Example stories set 11 | @available(iOS 15.0, macOS 12.0, tvOS 16.0, watchOS 10.0, *) 12 | public enum Stories: IStory { 13 | case first 14 | case second 15 | case third 16 | case fourth 17 | case fifth 18 | 19 | @ViewBuilder 20 | /// Define view template for every story 21 | public func builder(progress: Binding) -> some View { 22 | switch self { 23 | case .first: StoryTpl(self, .green, "1", progress) 24 | case .second: StoryTpl(self, .brown, "2", progress) 25 | case .third: StoryTpl(self, .purple, "3", progress) 26 | case .fourth: StoryTpl(self, .yellow, "4", progress) 27 | case .fifth: StoryTpl(self, .orange, "5", progress) 28 | } 29 | } 30 | 31 | /// Define every story duration or just one as a default for everyone 32 | public var duration: TimeInterval { 33 | switch self { 34 | case .first, .third: return 2 35 | default: return 3 36 | } 37 | } 38 | 39 | /// Optional param to define color scheme for some stories 40 | /// Sometimes one story demands light scheme the other demands dark because of story's design 41 | public var colorScheme: ColorScheme? { 42 | switch self { 43 | case .first: return .light 44 | case .fourth: return .light 45 | default: return .dark 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/protocol/IStoriesManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IStoriesManager.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 23.06.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Interface for managing stories life circle for ``StoriesWidget`` 11 | /// Define your own manager conforming to Stories Manager if you need some specific managing processes 12 | @available(iOS 15.0, macOS 12.0, tvOS 16.0, watchOS 10.0, *) 13 | public protocol IStoriesManager: ObservableObject { 14 | associatedtype Element: IStory 15 | 16 | /// Time progress demonstrating the current story 17 | var progress: CGFloat { get set } 18 | 19 | /// Current stories state 20 | /// Life circle: Start - ... Begin - (Suspend) - (Resume) - End ... - Finish 21 | var state: StoriesState { get } 22 | 23 | /// Check is suspended 24 | var suspended: Bool { get } 25 | 26 | /// Time buffer after suspension when Tap gesture is valid to move to the next story 27 | var tapTime: Bool { get } 28 | 29 | // MARK: - Config 30 | 31 | /// Set of stories 32 | var stories: [Element] { get } 33 | 34 | /// Current story 35 | var current: Element { get } 36 | 37 | /// One of the strategy defined in enum ``Strategy`` 38 | var strategy: Strategy { get } 39 | 40 | /// Delay before start counting stories time 41 | var leeway: DispatchTimeInterval { get } 42 | 43 | // MARK: - API 44 | 45 | /// Start showing stories 46 | func start() 47 | 48 | /// Pause showing stories 49 | func suspend() 50 | 51 | /// Resume showing stories 52 | func resume() 53 | 54 | /// Next story 55 | func next() 56 | 57 | /// Previous story 58 | func previouse() 59 | 60 | /// Finish showing stories 61 | func finish() 62 | 63 | // MARK: - Life circle 64 | 65 | init( 66 | stories: [Element], 67 | current: Element?, 68 | strategy: Strategy, 69 | leeway: DispatchTimeInterval 70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/ProgressBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProgressBar.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 23.06.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Indicate time progress for ``StoriesView`` component 11 | struct ProgressBar: View { 12 | /// ProgressBar configuration: height, spacing, active & inactive colors 13 | let config: ProgressBarConfig 14 | 15 | // MARK: - Config 16 | 17 | /// Set of data 18 | let stories: [Item] 19 | 20 | /// Current item from data set 21 | let current: Item 22 | 23 | /// Progress of showing current item 24 | let progress: CGFloat 25 | 26 | // MARK: - Life circle 27 | 28 | var body: some View { 29 | HStack(spacing: config.spacing) { 30 | ForEach(stories, id: \.self) { story in 31 | GeometryReader { proxy in 32 | let width = proxy.size.width 33 | itemTpl(story, width) 34 | } 35 | } 36 | }.frame(height: config.height) 37 | } 38 | 39 | // MARK: - private 40 | 41 | /// Progress slot view 42 | @ViewBuilder 43 | private func itemTpl(_ item: Item, _ width: CGFloat) -> some View { 44 | config.inactiveColor 45 | .overlay(progressTpl(item, width, current), alignment: .leading) 46 | .clipShape(Capsule()) 47 | } 48 | 49 | /// Progress slot overlay view 50 | /// - Parameters: 51 | /// - item: Story 52 | /// - width: Available space 53 | /// - current: Current story 54 | /// - Returns: View 55 | @ViewBuilder 56 | private func progressTpl(_ item: Item, _ width: CGFloat, _ current: Item) -> some View { 57 | if item.isBefore(current) { // has already passed 58 | config.activeColor 59 | } else if item == current { 60 | config.activeColor.frame(width: progress * width) // current progress 61 | } else { 62 | EmptyView() 63 | } 64 | } 65 | } 66 | 67 | struct ProgressBar_Previews: PreviewProvider { 68 | static var previews: some View { 69 | StoriesView(manager: StoriesManager.self, stories: Stories.allCases, pause: .constant(false)) 70 | .preferredColorScheme(.dark) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/example/StoryTpl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryTpl.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 28.06.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Example stories template 11 | @available(iOS 15.0, macOS 12.0, tvOS 16.0, watchOS 10.0, *) 12 | public struct StoryTpl: IStoryTpl { 13 | /// Detecting color scheme 14 | @Environment(\.colorScheme) private var colorScheme: ColorScheme 15 | 16 | // MARK: - Config 17 | 18 | /// background color 19 | let color: Color 20 | 21 | /// Story text 22 | let text: String 23 | 24 | /// Current progress 25 | @Binding public var progress: CGFloat 26 | 27 | /// Story 28 | public let story: T 29 | 30 | // MARK: - Life circle 31 | 32 | public init(_ story: T, _ color: Color, _ text: String, _ progress: Binding) { 33 | self.story = story 34 | self.color = color 35 | self.text = text 36 | _progress = progress 37 | } 38 | 39 | public var body: some View { 40 | color 41 | .ignoresSafeArea() 42 | .overlay(textBuilder) 43 | } 44 | 45 | // MARK: - Private 46 | 47 | @ViewBuilder 48 | private func textBuilder(_ text: String, size: CGFloat = 350) -> some View { 49 | VStack { 50 | Text(text).font(.system(size: size, weight: .bold, design: .rounded)) 51 | } 52 | } 53 | 54 | @ViewBuilder 55 | private var textTpl: some View { 56 | let d = 180 - (180 * progress) 57 | VStack { 58 | textBuilder(text) 59 | .scaleEffect(progress) 60 | .rotation3DEffect(.degrees(d), axis: (x: 0, y: 1, z: 0)) 61 | .opacity(progress) 62 | 63 | textBuilder("story", size: 50) 64 | }.environment(\.colorScheme, story.colorScheme ?? colorScheme) 65 | .padding(.bottom, 60) 66 | } 67 | 68 | @ViewBuilder 69 | private var textBuilder: some View { 70 | #if os(iOS) 71 | textTpl 72 | #else 73 | textTpl.drawingGroup() 74 | #endif 75 | } 76 | } 77 | 78 | struct StoryTpl_Previews: PreviewProvider { 79 | static var previews: some View { 80 | EmptyView() 81 | StoryTpl(Stories.first, .yellow, "Story", .constant(0.1)) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | .DS_Store 93 | /.build 94 | /Packages 95 | /*.xcodeproj 96 | xcuserdata/ 97 | DerivedData/ 98 | .swiftpm/ 99 | -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/protocol/IStory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IStory.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 23.06.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Interface defining story view 11 | @available(iOS 15.0, macOS 12.0, tvOS 16.0, watchOS 10.0, *) 12 | public protocol IStory: Hashable, CaseIterable { 13 | associatedtype ViewTpl: View 14 | 15 | // MARK: - Config 16 | 17 | /// Optional param to define color scheme for some stories 18 | /// Sometimes one story demands light scheme the other demands dark because of story's design 19 | var colorScheme: ColorScheme? { get } 20 | 21 | /// Story duration 22 | var duration: TimeInterval { get } 23 | 24 | // MARK: - API 25 | 26 | /// Define view template for every story 27 | func builder(progress: Binding) -> ViewTpl 28 | 29 | /// Check the position relatively the currently showing story 30 | func isBefore(_ current: Self) -> Bool 31 | 32 | /// Get next element 33 | var next: Self { get } 34 | 35 | /// Get previous element 36 | var previous: Self { get } 37 | } 38 | 39 | public extension IStory { 40 | /// Default scheme 41 | var colorScheme: ColorScheme? { nil } 42 | 43 | /// Check the position relatively the currently showing story 44 | /// - Parameter current: Current story 45 | /// - Returns: true - `self` is before current 46 | func isBefore(_ current: Self) -> Bool { 47 | let all = Self.allCases 48 | 49 | guard let itemIdx = all.firstIndex(of: current) else { 50 | return false 51 | } 52 | 53 | guard let idx = all.firstIndex(of: self) else { 54 | return false 55 | } 56 | 57 | return idx < itemIdx 58 | } 59 | 60 | /// Get next element 61 | /// - Returns: previous element or current if previous does not exist 62 | var next: Self { 63 | let all = Self.allCases 64 | let startIndex = all.startIndex 65 | let endIndex = all.endIndex 66 | 67 | guard let idx = all.firstIndex(of: self) else { 68 | return self 69 | } 70 | 71 | let next = all.index(idx, offsetBy: 1) 72 | 73 | return next == endIndex ? all[startIndex] : all[next] 74 | } 75 | 76 | /// Get previous element 77 | /// - Returns: previous element or current if previous does not exist 78 | var previous: Self { 79 | let all = Self.allCases 80 | let startIndex = all.startIndex 81 | let endIndex = all.index(all.endIndex, offsetBy: -1) 82 | 83 | guard let idx = all.firstIndex(of: self) else { 84 | return self 85 | } 86 | 87 | let previous = all.index(idx, offsetBy: -1) 88 | 89 | return previous < startIndex ? all[endIndex] : all[previous] 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/viewmodel/StateManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StateManager.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 27.06.2022. 6 | // 7 | 8 | import Combine 9 | import SwiftUI 10 | 11 | /// Managing stories life circle 12 | final class StateManager { 13 | /// Sorry:) 0.00001 - fixing bug with animation in SwiftUI 14 | static let startProgress = 0.00001 15 | 16 | /// Publisher for posting states ``StoriesState`` 17 | let publisher = PassthroughSubject() 18 | 19 | private var timerSubscription: AnyCancellable? 20 | 21 | /// Current progress 22 | private var progress: CGFloat = StateManager.startProgress 23 | 24 | /// When story started 25 | private var startTime: Date? 26 | 27 | /// Story duration 28 | private var duration: TimeInterval = 0 29 | 30 | // MARK: - Life circle 31 | 32 | deinit { 33 | #if DEBUG 34 | print("deinit StateManager") 35 | #endif 36 | } 37 | 38 | // MARK: - Private 39 | 40 | /// Schedule and start timer 41 | private func schedule(_ duration: TimeInterval) { 42 | updateStartTime() 43 | 44 | timerSubscription = Timer.publish(every: duration, on: .main, in: .default) 45 | .autoconnect() 46 | .sink { [weak self] _ in 47 | self?.timerSubscription = nil 48 | self?.publishNext() 49 | } 50 | } 51 | 52 | private func publishNext() { 53 | publisher.send(.end) 54 | } 55 | 56 | private func updateStartTime() { 57 | let passed = progress * duration 58 | startTime = Date() - TimeInterval(passed) 59 | } 60 | 61 | // MARK: - API 62 | 63 | /// Pause showing stories 64 | public func suspend() { 65 | let now = Date() 66 | let hasPassed = now.timeIntervalSince(startTime ?? now) 67 | progress = hasPassed / duration 68 | timerSubscription = nil 69 | 70 | publisher.send(.suspend(progress)) 71 | } 72 | 73 | /// Resume showing stories 74 | public func resume() { 75 | let left = duration - (progress * duration) 76 | 77 | schedule(left) 78 | publisher.send(.resume(progress)) 79 | } 80 | 81 | /// Start big stories circle 82 | /// - Parameters: 83 | /// - duration: Duration for the first story 84 | /// - leeway: Delay before start 85 | public func start(_ duration: TimeInterval, leeway: DispatchTimeInterval) { 86 | publisher.send(.start) 87 | DispatchQueue.main.asyncAfter(deadline: .now() + leeway) { [weak self] in 88 | self?.begin(duration) 89 | } 90 | } 91 | 92 | /// Start showing story 93 | public func begin(_ duration: TimeInterval) { 94 | self.duration = duration 95 | progress = StateManager.startProgress 96 | 97 | schedule(duration) 98 | publisher.send(.begin) 99 | } 100 | 101 | /// Finish showing stories 102 | public func finish() { 103 | publisher.send(.finish) 104 | timerSubscription = nil 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/StoriesWidget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoriesWidget.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 28.06.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Widget demonstrating stories 11 | @available(iOS 15.0, macOS 12.0, tvOS 16.0, watchOS 10.0, *) 12 | public struct StoriesWidget: View { 13 | public typealias Item = M.Element 14 | 15 | // MARK: - Config 16 | 17 | /// Managing stories life circle 18 | let manager: M.Type 19 | 20 | /// Set of stories 21 | let stories: [M.Element] 22 | 23 | /// Start story 24 | let current: Item? 25 | 26 | /// `.once` or `.circle` 27 | let strategy: Strategy 28 | 29 | /// Delay before start stories 30 | let leeway: DispatchTimeInterval 31 | 32 | /// Shared var to control stories running process by external controls that are not inside StoriesWidget 33 | var pause: Binding 34 | 35 | /// React on stories state change 36 | let onStoriesStateChanged: ((StoriesState) -> Void)? 37 | 38 | /// Custom validator to check validity of stories data set 39 | let validator: IStoriesValidater.Type? 40 | 41 | /// ProgressBar configuration 42 | private let progressBarConfig: ProgressBarConfig 43 | 44 | // MARK: - Life circle 45 | 46 | /// - Parameters: 47 | /// - manager: Start story 48 | /// - stories: Set of stories 49 | /// - current: Story for starting 50 | /// - strategy: `.once` or `.circle` 51 | /// - leeway: Delay before start stories 52 | /// - pause: Pause and resume control from out side environment 53 | /// - validator: Custom validator for stories input data set 54 | /// - progressBarConfig: ProgressBar configuration 55 | /// - onStoriesStateChanged: Closure to react on stories state change 56 | public init( 57 | manager: M.Type, 58 | stories: [M.Element], 59 | current: Item? = nil, 60 | strategy: Strategy = .circle, 61 | leeway: DispatchTimeInterval = .seconds(0), 62 | pause: Binding = .constant(false), 63 | validator: IStoriesValidater.Type? = nil, 64 | progressBarConfig: ProgressBarConfig = .init(), 65 | onStoriesStateChanged: ((StoriesState) -> Void)? 66 | ) { 67 | self.manager = manager 68 | self.stories = stories 69 | self.current = current 70 | self.strategy = strategy 71 | self.leeway = leeway 72 | self.pause = pause 73 | self.validator = validator 74 | self.progressBarConfig = progressBarConfig 75 | self.onStoriesStateChanged = onStoriesStateChanged 76 | } 77 | 78 | /// The content and behavior of the view. 79 | public var body: some View { 80 | let e = validate() 81 | 82 | if e.isEmpty { 83 | StoriesView( 84 | manager: manager, 85 | stories: stories, 86 | current: current, 87 | strategy: strategy, 88 | leeway: leeway, 89 | pause: pause, 90 | progressBarConfig: progressBarConfig 91 | ) 92 | .onPreferenceChange(StoriesStateKey.self) { state in 93 | onStoriesStateChanged?(state) 94 | } 95 | 96 | } else { 97 | StoriesError.builder(e) 98 | } 99 | } 100 | 101 | /// Validate stories set 102 | /// - Returns: Set of errors or empty array 103 | private func validate() -> [StoriesError] { 104 | var errors = StoriesInternalError.validate(stories) 105 | 106 | if let v = validator { 107 | errors += v.validate(stories) 108 | } 109 | 110 | return errors 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/viewmodel/StoriesManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoriesManager.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 23.06.2022. 6 | // 7 | 8 | import Combine 9 | import SwiftUI 10 | 11 | /// Managing logic for ``StoriesWidget`` component 12 | @available(iOS 15.0, macOS 12.0, tvOS 16.0, watchOS 10.0, *) 13 | public final class StoriesManager: IStoriesManager { 14 | /// Time progress demonstrating the current story 15 | @Published public var progress: CGFloat = StateManager.startProgress 16 | 17 | /// Current stories state 18 | /// Life circle: Start - ... Begin - (Suspend) - (Resume) - End ... - Finish 19 | @Published public private(set) var state: StoriesState = .ready 20 | 21 | /// Check is suspended 22 | public var suspended: Bool { if case .suspend = state { return true } else { return false } } 23 | 24 | /// State manager 25 | private let manager = StateManager() 26 | 27 | /// Subscriptions 28 | private var sub: AnyCancellable? 29 | 30 | @Published public var tapTime: Bool = false 31 | 32 | // MARK: - Config 33 | 34 | /// Current story 35 | @Published public private(set) var current: Item 36 | 37 | /// Set of stories 38 | @Published public private(set) var stories: [Item] 39 | 40 | /// One of the strategy defined in enum ``Strategy`` 41 | public let strategy: Strategy 42 | 43 | /// Delay before start counting stories time 44 | public let leeway: DispatchTimeInterval 45 | 46 | // MARK: - Life circle 47 | 48 | /// Delay before start showing stories 49 | public init( 50 | stories: [Item], 51 | current: Item? = nil, 52 | strategy: Strategy = .circle, 53 | leeway: DispatchTimeInterval = .seconds(0) 54 | ) { 55 | self.stories = stories 56 | self.current = current ?? stories.first! 57 | self.strategy = strategy 58 | self.leeway = leeway 59 | 60 | sub = manager 61 | .publisher 62 | .sink { [weak self] in 63 | self?.onStateChanged($0) 64 | } 65 | } 66 | 67 | deinit { 68 | #if DEBUG 69 | print("deinit StoriesManager") 70 | #endif 71 | } 72 | 73 | // MARK: - API 74 | 75 | /// Pause showing stories 76 | public func suspend() { 77 | if suspended { return } 78 | 79 | manager.suspend() 80 | 81 | tapTime = true 82 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in 83 | self?.tapTime = false 84 | } 85 | } 86 | 87 | /// Rsume showing stories 88 | public func resume() { 89 | manager.resume() 90 | } 91 | 92 | /// Start showing stories 93 | public func start() { 94 | manager.start(current.duration, leeway: leeway) 95 | } 96 | 97 | /// Finish showing stories 98 | public func finish() { 99 | manager.finish() 100 | } 101 | 102 | /// Next story 103 | public func next() { 104 | current = current.next 105 | if validateOnce(current) { return finish() } 106 | manager.begin(current.duration) 107 | } 108 | 109 | /// Previous story 110 | public func previouse() { 111 | current = current.previous 112 | manager.begin(current.duration) 113 | } 114 | 115 | // MARK: - Private 116 | 117 | private func validateOnce(_ next: Item) -> Bool { 118 | strategy == .once && next == stories.first 119 | } 120 | 121 | /// Process state change 122 | /// - Parameter state: Stories showcase state 123 | private func onStateChanged(_ state: StoriesState) { 124 | /// Need this to overcome SwiftUI view update specifics 125 | if state != .begin { self.state = state } 126 | 127 | switch state { 128 | case .begin: initAnimation() 129 | case .end: next() 130 | case let .suspend(progress): return suspendAnimation(progress) 131 | case let .resume(progress): resumeAnimation(progress) 132 | default: return 133 | } 134 | /// Need this to overcome SwiftUI view update specifics 135 | if state == .begin { self.state = state } 136 | } 137 | 138 | /// Typical time slot for a story 139 | private func initAnimation() { 140 | progress = StateManager.startProgress 141 | runAnimation(1, current.duration) 142 | } 143 | 144 | private func suspendAnimation(_ progress: CGFloat) { 145 | runAnimation(progress, 0) 146 | } 147 | 148 | private func resumeAnimation(_ progress: CGFloat) { 149 | let duration = (1 - progress) * current.duration 150 | runAnimation(1, duration) 151 | } 152 | 153 | /// Costumed animation for progress 154 | /// - Parameters: 155 | /// - progress: Endpoint progress 156 | /// - duration: Time 157 | private func runAnimation(_ progress: CGFloat, _ duration: Double) { 158 | withAnimation(.linear(duration: duration)) { 159 | self.progress = progress 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /Sources/d3-stories-instagram/StoriesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoriesView.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 23.06.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Component demonstrating stories 11 | struct StoriesView: View { 12 | typealias Item = M.Element 13 | 14 | /// Detecting color scheme 15 | @Environment(\.colorScheme) private var colorScheme: ColorScheme 16 | 17 | /// Managing stories life circle for ``StoriesView`` component 18 | @StateObject private var model: M 19 | 20 | /// Shared var to control stories running process by external controls that are not inside StoriesWidget 21 | private var pause: Binding 22 | 23 | /// ProgressBar configuration 24 | private let progressBarConfig: ProgressBarConfig 25 | 26 | // MARK: - Life circle 27 | 28 | /// - Parameters: 29 | /// - manager: Start story 30 | /// - current: Start story 31 | /// - strategy: `.once` or `.circle` 32 | /// - leeway: Delay before start stories 33 | /// - stories: Set of stories 34 | /// - progressBarConfig: progressBar configuration: height, spacing, active & inactive colors 35 | init( 36 | manager: M.Type, 37 | stories: [Item], 38 | current: Item? = nil, 39 | strategy: Strategy = .circle, 40 | leeway: DispatchTimeInterval = .seconds(0), 41 | pause: Binding, 42 | progressBarConfig: ProgressBarConfig = .init() 43 | ) { 44 | self.pause = pause 45 | self.progressBarConfig = progressBarConfig 46 | 47 | _model = StateObject(wrappedValue: 48 | manager.init(stories: stories, current: current, strategy: strategy, leeway: leeway) 49 | ) 50 | } 51 | 52 | /// The content and behavior of the view. 53 | var body: some View { 54 | GeometryReader { proxy in 55 | let h = proxy.size.height / 25 56 | bodyTpl 57 | .overlay(directionControl) 58 | progressView 59 | .padding(.top, h) 60 | } 61 | .environment(\.colorScheme, model.current.colorScheme ?? colorScheme) 62 | .onAppear(perform: model.start) 63 | .onDisappear(perform: model.finish) 64 | .onChange(of: pause.wrappedValue, perform: onPause) 65 | .preference(key: StoriesStateKey.self, value: model.state) 66 | } 67 | 68 | // MARK: - Private 69 | 70 | /// Process pause, resume actions Check suspended as action can come from Gesture or external source to pause or resume stories run 71 | /// - Parameter value: true - pause, false - resume 72 | private func onPause(value: Bool) { 73 | if value { 74 | if !model.suspended { 75 | model.suspend() 76 | } 77 | } else { 78 | if model.suspended { 79 | model.resume() 80 | } 81 | } 82 | } 83 | 84 | /// Managing suspend and resume states 85 | private var gesture: some Gesture { 86 | #if os(tvOS) 87 | // Alternative implementation for tvOS 88 | return TapGesture(count: 1) 89 | .onEnded { 90 | if model.suspended { 91 | pause.wrappedValue = false 92 | model.resume() 93 | } else { 94 | pause.wrappedValue = true 95 | model.suspend() 96 | } 97 | } 98 | #else 99 | // Original implementation for non-tvOS platforms 100 | return DragGesture(minimumDistance: 0) 101 | .onChanged { _ in 102 | if !model.suspended { 103 | pause.wrappedValue = true 104 | model.suspend() 105 | } 106 | } 107 | .onEnded { _ in 108 | pause.wrappedValue = false 109 | model.resume() 110 | } 111 | #endif 112 | } 113 | 114 | /// Cover controls for step forward and backward and pause 115 | /// from width: 25% cover - step backward, 75% - step forward 116 | /// Long press on 75% - to pause 117 | @ViewBuilder 118 | private var directionControl: some View { 119 | GeometryReader { proxy in 120 | let w = proxy.size.width 121 | Color.white.opacity(0.001) 122 | .onTapGesture { 123 | if model.tapTime { 124 | model.next() 125 | } 126 | } 127 | .simultaneousGesture(gesture) 128 | Color.white.opacity(0.001) 129 | .frame(width: w * 0.25) 130 | .onTapGesture { 131 | model.previouse() 132 | } 133 | .simultaneousGesture(gesture) 134 | } 135 | } 136 | 137 | /// Body template for current story defined in ``IStory`` property ```builder``` 138 | @ViewBuilder 139 | private var bodyTpl: some View { 140 | model.current.builder(progress: $model.progress) 141 | } 142 | 143 | /// Progress bar builder 144 | @ViewBuilder 145 | private var progressView: some View { 146 | ProgressBar( 147 | config: progressBarConfig, 148 | stories: model.stories, 149 | current: model.current, 150 | progress: model.progress 151 | ).padding(.horizontal) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUI and Combine - Stories intro multi-platform widget 2 | 3 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fswiftuiux%2Fd3-stories-instagram%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/swiftuiux/d3-stories-instagram) 4 | 5 | ## [SwiftUI example](https://github.com/swiftuiux/d3-stories-instagram-example) 6 | 7 | ## Features 8 | - [x] Long tap - pause stories showcase 9 | - [x] Tap - next story 10 | - [x] Leeway - pause before start stories 11 | - [x] Customize component with you own stories and every story with it's own view 12 | - [x] Customize time longevity for every story 13 | - [x] iOS and macOS support 14 | - [x] Customizable **dark** and **light** scheme support for every story 15 | - [x] Control stories run as by external sources that are not inside StoriesWidget so via Gesture 16 | - [x] Observing stories life circle for reacting on state change 17 | - [x] Internal and custom external errors handling 18 | - [x] Localization (En, Es) All errors and system messages are localized 19 | 20 | ## 1. Stories 21 | Define enum with your stories conforming to **IStory** 22 | 23 | ```swift 24 | public enum Stories: IStory { 25 | 26 | case first 27 | case second 28 | case third 29 | 30 | @ViewBuilder 31 | public func builder(progress : Binding) -> some View { 32 | switch(self) { 33 | case .first: StoryTpl(self, .green, "1", progress) 34 | case .second: StoryTpl(self, .brown, "2", progress) 35 | case .third: StoryTpl(self, .purple, "3", progress) 36 | } 37 | } 38 | 39 | public var duration: TimeInterval { 40 | switch self{ 41 | case .first, .third : return 5 42 | default : return 3 43 | } 44 | } 45 | 46 | public var colorScheme: ColorScheme? { 47 | switch(self) { 48 | case .first: return .light 49 | default: return .dark 50 | } 51 | } 52 | 53 | } 54 | ``` 55 | 56 | ## 2. Create stories widget 57 | 58 | * `manager` - package standard manager **StoriesManager.self** for managing stories life circle.
*Define your own manager conforming to **IStoriesManager** if you need some specific managing process* 59 | * `stories` - stories conforming to **IStory** 60 | 61 | ```Swift 62 | StoriesWidget( 63 | manager: StoriesManager.self, 64 | stories: Stories.allCases 65 | ) 66 | ``` 67 | 68 | ### Optional 69 | 70 | * `strategy` - default strategy is **circle** 71 | 72 | | Strategy | Description | 73 | | --- | --- | 74 | |**circle**| Repeat stories | 75 | |**once**| Show just once | 76 | 77 | 78 | * `current` - start story if not defined start with first 79 | 80 | * `leeway` - delay before start stories, default **.seconds(0)** 81 | 82 | * `pause` - shared var to control stories run by external sources that are not inside StoriesWidget, default **.constant(false)**. For example if you launched modal view and need to pause running stories while modal view is existed you can do it via shared variable passing as a binding in StoriesWidget. 83 | * `validator` - Custom validator to check validity of stories data set before start 84 | * `onStoriesStateChanged` - Closure to react on stories state change 85 | 86 | ## [Add video component](https://github.com/swiftuiux/swiftui-video-player-example) 87 | 88 | ![The concept](https://github.com/swiftuiux/swiftui-loop-videoplayer-example/blob/main/swiftui-loop-videoplayer-example/img/swiftui_video_player.gif) 89 | 90 | ## Stories life circle 91 | You can observe events of the stories life circle and react on it's change. Pass closure to config of **StoriesWidget**.
92 | - **onStoriesStateChanged(**StoriesState**)** - Closure to react on stories state change 93 | 94 | ``` swift 95 | StoriesWidget( 96 | manager: StoriesManager.self, 97 | stories: Stories.allCases 98 | ){ state in 99 | print("Do something on stories \(state) change") 100 | } 101 | ``` 102 | 103 | | State | Description | 104 | | --- | --- | 105 | |**ready**| Waiting to start If there's leeway this is the state during this delay before the big start | 106 | |**start**| Big start | 107 | |**begin**| Begin of a story | 108 | |**end**| End of a story | 109 | |**suspend**| At the moment of pause and then is kept until is resumed. Informs that currently demonstration is paused | 110 | |**resume**| At the moment of resume and then is kept until the next pause or end of a story | 111 | |**finish**| Big finish. At the end of the strategy **.once** | 112 | 113 | ![Stories life circle](https://github.com/swiftuiux/d3-stories-instagram/blob/main/img/stories_state.png) 114 | 115 | ## Stories error handling 116 | There's internal check of stories data 117 | - There are no stories 118 | - Duration must be a positive number greater than zero 119 | 120 | ### Custom stories error handling 121 | 122 | if you need custom check for stories data, just implement validator conforming to **IStoriesValidater** and pass it as a parameter to **StoriesWidget** 123 | 124 | ```Swift 125 | StoriesWidget( 126 | manager: StoriesManager.self, 127 | stories: Stories.allCases, 128 | validator: CustomStoriesValidater.self 129 | ) 130 | ``` 131 | There's an example of custom validator. Take a look on 132 | [**CustomStoriesValidater**](https://github.com/swiftuiux/d3-stories-instagram/blob/main/Sources/d3-stories-instagram/example/CustomStoriesValidater.swift) implementation. Stories won't be started if there's an error then instead of stories there'll be the error view with description of errors. 133 | 134 | ![Custom error handling for stories](https://github.com/swiftuiux/d3-stories-instagram/blob/main/img/errors_handling.png) 135 | 136 | ## Localization (En, Es) 137 | All the internal errors and system messages that might occur are localized. Localization for stories is up to you as it's external source for the component. 138 | 139 | *Se localizan todos los errores internos y mensajes del sistema que puedan producirse. La localización de las historias depende de usted, ya que es la fuente externa del componente.* 140 | 141 | ![Custom stories error handling](https://github.com/swiftuiux/d3-stories-instagram/blob/main/img/localization.png) 142 | 143 | 144 | [![click to watch expected UI behavior for the example](https://github.com/swiftuiux/d3-stories-instagram/blob/main/img/img_01.gif)](https://youtu.be/GW01UyqzaeE) 145 | 146 | [![click to watch expected UI behavior for the example](https://github.com/swiftuiux/d3-stories-instagram/blob/main/img/img_08.gif)](https://youtu.be/GW01UyqzaeE) 147 | 148 | [![click to watch expected UI behavior for the example](https://github.com/swiftuiux/d3-stories-instagram/blob/main/img/img_03.png)](https://youtu.be/GW01UyqzaeE) 149 | 150 | ## Documentation(API) 151 | - You need to have Xcode 13 installed in order to have access to Documentation Compiler (DocC) 152 | - Go to Product > Build Documentation or **⌃⇧⌘ D** 153 | --------------------------------------------------------------------------------