├── .gitignore ├── Tests └── SlideshowTests │ └── SlideshowTests.swift ├── Sources └── Slideshow │ ├── UserDefault+AnimatedOffset.swift │ ├── View+ReceiveTimer.swift │ ├── SlideshowAutoScroll.swift │ ├── AppLifeCycleModifier.swift │ ├── Slideshow.swift │ └── SlideshowViewModel.swift ├── Package.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Tests/SlideshowTests/SlideshowTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Slideshow 3 | 4 | final class SlideshowTests: 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(Slideshow().text, "Hello, World!") 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/Slideshow/UserDefault+AnimatedOffset.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaults+AnimatedOffset.swift 3 | // 4 | // 5 | // Created by HumorousGhost on 2022/7/28. 6 | // 7 | 8 | import Foundation 9 | 10 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 11 | internal extension UserDefaults { 12 | private struct Keys { 13 | static let isAnimatedOffset = "Slideshow+isAnimatedOffset" 14 | } 15 | 16 | static var isAnimatedOffset: Bool { 17 | get { 18 | return UserDefaults.standard.bool(forKey: Keys.isAnimatedOffset) 19 | } 20 | set { 21 | UserDefaults.standard.set(newValue, forKey: Keys.isAnimatedOffset) 22 | } 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Slideshow/View+ReceiveTimer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+ReceiveTimer.swift 3 | // 4 | // 5 | // Created by HumorousGhost on 2022/7/28. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 12 | typealias TimePublisher = Publishers.Autoconnect 13 | 14 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 15 | extension View { 16 | func onReceive(timer: TimePublisher?, perform action: @escaping (Timer.TimerPublisher.Output) -> Void) -> some View { 17 | Group { 18 | if let timer = timer { 19 | self.onReceive(timer) { value in 20 | action(value) 21 | } 22 | } else { 23 | self 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/Slideshow/SlideshowAutoScroll.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SlideshowAutoScroll.swift 3 | // 4 | // 5 | // Created by HumorousGhost on 2022/7/28. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 11 | public enum SlideshowAutoScroll { 12 | case inactive 13 | case active(TimeInterval) 14 | } 15 | 16 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 17 | extension SlideshowAutoScroll { 18 | 19 | /// default active 20 | public static var defaultActive: Self { 21 | return .active(5) 22 | } 23 | 24 | /// Is the view auto-scrolling 25 | var isActive: Bool { 26 | switch self { 27 | case .inactive: 28 | return false 29 | case .active(let timeInterval): 30 | return timeInterval > 0 31 | } 32 | } 33 | 34 | /// Duration of automatic scrolling 35 | var interval: TimeInterval { 36 | switch self { 37 | case .inactive: 38 | return 0 39 | case .active(let timeInterval): 40 | return timeInterval 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /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: "Slideshow", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v10_15), 11 | .tvOS(.v13), 12 | .watchOS(.v6) 13 | ], 14 | products: [ 15 | // Products define the executables and libraries a package produces, and make them visible to other packages. 16 | .library( 17 | name: "Slideshow", 18 | targets: ["Slideshow"]), 19 | ], 20 | dependencies: [ 21 | // Dependencies declare other packages that this package depends on. 22 | // .package(url: /* package url */, from: "1.0.0"), 23 | ], 24 | targets: [ 25 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 26 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 27 | .target( 28 | name: "Slideshow", 29 | dependencies: []), 30 | .testTarget( 31 | name: "SlideshowTests", 32 | dependencies: ["Slideshow"]), 33 | ] 34 | ) 35 | -------------------------------------------------------------------------------- /Sources/Slideshow/AppLifeCycleModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppLifeCycleModifier.swift 3 | // 4 | // 5 | // Created by HumorousGhost on 2022/7/28. 6 | // 7 | 8 | import SwiftUI 9 | 10 | #if os(macOS) 11 | import AppKit 12 | typealias Application = NSApplication 13 | #else 14 | import UIKit 15 | typealias Application = UIApplication 16 | #endif 17 | 18 | /// Monitor and receive application life cycles, 19 | /// inactive or active 20 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 21 | struct AppLifeCycleModifier: ViewModifier { 22 | 23 | let active = NotificationCenter.default.publisher(for: Application.didBecomeActiveNotification) 24 | 25 | let inactive = NotificationCenter.default.publisher(for: Application.willResignActiveNotification) 26 | 27 | private let action: (Bool) -> Void 28 | 29 | init(_ action: @escaping (Bool) -> Void) { 30 | self.action = action 31 | } 32 | 33 | func body(content: Content) -> some View { 34 | content 35 | .onAppear { 36 | /// `onReceive` will not work in the Modifier Without `onAppear` 37 | } 38 | .onReceive(active) { _ in 39 | action(true) 40 | } 41 | .onReceive(inactive) { _ in 42 | action(false) 43 | } 44 | } 45 | } 46 | 47 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 48 | extension View { 49 | func onReceiveAppLifeCycle(perform action: @escaping (Bool) -> Void) -> some View { 50 | self.modifier(AppLifeCycleModifier(action)) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Slideshow 2 | 3 | This project is only for SwiftUI 4 | 5 | An automatic scrolling carousel similar to how `ScrollView` is used 6 | 7 | ## Preview 8 | 9 | ![image](https://github.com/HumorousGhost/RepositoryPerview/blob/main/slideshow_preview.gif) 10 | 11 | ## Supported Platforms 12 | 13 | * iOS 13.0 14 | * macOS 10.15 15 | * tvOS 13.0 16 | * watchOS 6.0 17 | 18 | ## Usage 19 | 20 | ```swift 21 | struct Item: Identifiable { 22 | let id = UUID() 23 | let image: String 24 | let title: String 25 | } 26 | 27 | let items = [ 28 | Item(image: "image1", title: "first"), 29 | Item(image: "image2", title: "second"), 30 | Item(image: "image3", title: "third"), 31 | Item(image: "image4", title: "fourth") 32 | ] 33 | 34 | var body: some View { 35 | VStack { 36 | Spacer() 37 | Slideshow(items, spacing: 20, isWrap: true, autoScroll: .active(2)) { item in 38 | itemView(item: item) 39 | .frame(width: 350, height: 200) 40 | .cornerRadius(16) 41 | } 42 | .frame(width: UIScreen.main.bounds.width, height: 200) 43 | Spacer() 44 | } 45 | } 46 | 47 | @ViewBuilder 48 | func itemView(item: Item) -> some View { 49 | ZStack { 50 | Image(item.image) 51 | .resizable() 52 | .aspectRatio(contentMode: .fill) 53 | .frame(height: 200) 54 | } 55 | } 56 | ``` 57 | 58 | ## Installation 59 | 60 | You can add Slideshow to an Xcode project by adding it as a package dependency 61 | 62 | * From the **File** menu, select **Swift Packages** > **Add Package Dependency...** 63 | * Enter https://github.com/HumorousGhost/Slideshow into the package repository URL text field. 64 | * Link **Slideshow** to your application target. 65 | -------------------------------------------------------------------------------- /Sources/Slideshow/Slideshow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Slideshow.swift 3 | // 4 | // 5 | // Created by HumorousGhost on 2022/7/28. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 11 | public struct Slideshow: View where Data: RandomAccessCollection, ID: Hashable, Content: View { 12 | 13 | @ObservedObject 14 | private var viewModel: SlideshowViewModel 15 | private let content: (Data.Element) -> Content 16 | 17 | public var body: some View { 18 | GeometryReader { proxy -> AnyView in 19 | viewModel.viewSize = proxy.size 20 | return AnyView(generateContent(proxy: proxy)) 21 | }.clipped() 22 | } 23 | 24 | @ViewBuilder 25 | private func generateContent(proxy: GeometryProxy) -> some View { 26 | HStack(spacing: viewModel.spacing) { 27 | ForEach(viewModel.data, id: viewModel.dataId) { 28 | content($0) 29 | .frame(width: viewModel.itemWidth) 30 | .scaleEffect(x: 1, y: viewModel.itemScaling($0), anchor: .center) 31 | } 32 | } 33 | .frame(width: proxy.size.width, height: proxy.size.height, alignment: .leading) 34 | .offset(x: viewModel.offset) 35 | .gesture(viewModel.dragGesture) 36 | .animation(viewModel.offsetAnimation, value: viewModel.offset) 37 | .onReceive(timer: viewModel.timer, perform: viewModel.receiveTimer) 38 | .onReceiveAppLifeCycle(perform: viewModel.setTimerActive) 39 | } 40 | } 41 | 42 | // MARK: - Initializers 43 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 44 | extension Slideshow { 45 | 46 | /// Initializers 47 | /// - Parameters: 48 | /// - data: Data 49 | /// - id: ID 50 | /// - index: active subview, default 0 51 | /// - spacing: default 10 52 | /// - headspace: default 10 53 | /// - sidesScaling: sides scale, default 0.8 54 | /// - isWrap: is cycle scroll, default false 55 | /// - autoScroll: is auto scroll, default .inactive 56 | /// - canMove: is manual scroll, default true 57 | /// - content: Content 58 | public init(_ data: Data, 59 | id: KeyPath, 60 | index: Binding = .constant(0), 61 | spacing: CGFloat = 10, 62 | headspace: CGFloat = 10, 63 | sidesScaling: CGFloat = 0.8, 64 | isWrap: Bool = false, 65 | autoScroll: SlideshowAutoScroll = .inactive, 66 | canMove: Bool = true, 67 | @ViewBuilder content: @escaping (Data.Element) -> Content) { 68 | self.viewModel = SlideshowViewModel(data, id: id, index: index, spacing: spacing, headspace: headspace, sidesScaling: sidesScaling, isWrap: isWrap, autoScroll: autoScroll, canMove: canMove) 69 | self.content = content 70 | } 71 | } 72 | 73 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 74 | extension Slideshow where ID == Data.Element.ID, Data.Element: Identifiable { 75 | 76 | /// Initializers 77 | /// - Parameters: 78 | /// - data: Data 79 | /// - index: active subview, default 0 80 | /// - spacing: default 10 81 | /// - headspace: default 10 82 | /// - sidesScaling: sides scale, default 0.8 83 | /// - isWrap: is cycle scroll, default false 84 | /// - autoScroll: is auto scroll, default .inactive 85 | /// - canMove: is manual scroll, default true 86 | /// - content: Content 87 | public init(_ data: Data, 88 | index: Binding = .constant(0), 89 | spacing: CGFloat = 10, 90 | headspace: CGFloat = 10, 91 | isWrap: Bool = false, 92 | sidesScaling: CGFloat = 0.8, 93 | autoScroll: SlideshowAutoScroll = .inactive, 94 | canMove: Bool = true, 95 | @ViewBuilder content: @escaping (Data.Element) -> Content) { 96 | self.viewModel = SlideshowViewModel(data, index: index, spacing: spacing, headspace: headspace, sidesScaling: sidesScaling, isWrap: isWrap, autoScroll: autoScroll, canMove: canMove) 97 | self.content = content 98 | } 99 | } 100 | 101 | @available(iOS 14.0, macOS 11.0, tvOS 13.0, watchOS 6.0, *) 102 | struct Slideshow_LibraryContent: LibraryContentProvider { 103 | let Datas = Array(repeating: _Item(color: .red), count: 3) 104 | @LibraryContentBuilder var views: [LibraryItem] { 105 | LibraryItem(Slideshow(Datas, content: { _ in }), title: "Slideshow", category: .control) 106 | LibraryItem(Slideshow(Datas, index: .constant(0), spacing: 10, headspace: 10, isWrap: false, sidesScaling: 0.8, autoScroll: .inactive, canMove: true, content: { _ in }), title: "Slideshow full parameters", category: .control) 107 | } 108 | 109 | struct _Item: Identifiable { 110 | let id = UUID() 111 | let color: Color 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Sources/Slideshow/SlideshowViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SlideshowViewModel.swift 3 | // 4 | // 5 | // Created by HumorousGhost on 2022/7/28. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 12 | class SlideshowViewModel: ObservableObject where Data : RandomAccessCollection, ID : Hashable { 13 | 14 | /// external index 15 | @Binding private var index: Int 16 | 17 | private let _data: Data 18 | private let _dataId: KeyPath 19 | private let _spacing: CGFloat 20 | private let _headspace: CGFloat 21 | private let _isWrap: Bool 22 | private let _sidesScaling: CGFloat 23 | private let _autoScroll: SlideshowAutoScroll 24 | private let _canMove: Bool 25 | 26 | init(_ data: Data, 27 | id: KeyPath, 28 | index: Binding, 29 | spacing: CGFloat, 30 | headspace: CGFloat, 31 | sidesScaling: CGFloat, 32 | isWrap: Bool, 33 | autoScroll: SlideshowAutoScroll, 34 | canMove: Bool) { 35 | 36 | guard index.wrappedValue < data.count else { 37 | fatalError("The index should be less than the count of data ") 38 | } 39 | 40 | self._data = data 41 | self._dataId = id 42 | self._spacing = spacing 43 | self._headspace = headspace 44 | self._isWrap = isWrap 45 | self._sidesScaling = sidesScaling 46 | self._autoScroll = autoScroll 47 | self._canMove = canMove 48 | 49 | if data.count > 1 && isWrap { 50 | activeIndex = index.wrappedValue + 1 51 | } else { 52 | activeIndex = index.wrappedValue 53 | } 54 | 55 | self._index = index 56 | } 57 | 58 | 59 | /// The index of the currently active subview. 60 | @Published var activeIndex: Int = 0 { 61 | willSet { 62 | if isWrap { 63 | if newValue > _data.count || newValue == 0 { 64 | return 65 | } 66 | index = newValue - 1 67 | } else { 68 | index = newValue 69 | } 70 | } 71 | didSet { 72 | changeOffset() 73 | } 74 | } 75 | 76 | /// Offset x of the view drag. 77 | @Published var dragOffset: CGFloat = .zero 78 | 79 | /// size of GeometryProxy 80 | var viewSize: CGSize = .zero 81 | 82 | 83 | /// Counting of time 84 | /// work when `isTimerActive` is true 85 | /// Toggles the active subviewview and resets if the count is the same as 86 | /// the duration of the auto scroll. Otherwise, increment one 87 | private var timing: TimeInterval = 0 88 | 89 | /// Define listen to the timer 90 | /// Ignores listen while dragging, and listen again after the drag is over 91 | /// Ignores listen when App will resign active, and listen again when it become active 92 | private var isTimerActive = true 93 | 94 | func setTimerActive(_ active: Bool) { 95 | isTimerActive = active 96 | } 97 | 98 | } 99 | 100 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 101 | extension SlideshowViewModel where ID == Data.Element.ID, Data.Element : Identifiable { 102 | 103 | convenience init(_ data: Data, 104 | index: Binding, 105 | spacing: CGFloat, 106 | headspace: CGFloat, 107 | sidesScaling: CGFloat, 108 | isWrap: Bool, 109 | autoScroll: SlideshowAutoScroll, 110 | canMove: Bool) { 111 | self.init(data, id: \.id, index: index, spacing: spacing, headspace: headspace, sidesScaling: sidesScaling, isWrap: isWrap, autoScroll: autoScroll, canMove: canMove) 112 | } 113 | } 114 | 115 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 116 | extension SlideshowViewModel { 117 | 118 | var data: Data { 119 | guard _data.count != 0 else { 120 | return _data 121 | } 122 | guard _data.count > 1 else { 123 | return _data 124 | } 125 | guard isWrap else { 126 | return _data 127 | } 128 | return [_data.last!] + _data + [_data.first!] as! Data 129 | } 130 | 131 | var dataId: KeyPath { 132 | return _dataId 133 | } 134 | 135 | var spacing: CGFloat { 136 | return _spacing 137 | } 138 | 139 | var offsetAnimation: Animation? { 140 | guard isWrap else { 141 | return .spring() 142 | } 143 | return isAnimatedOffset ? .spring() : .none 144 | } 145 | 146 | var itemWidth: CGFloat { 147 | max(0, viewSize.width - defaultPadding * 2) 148 | } 149 | 150 | var timer: TimePublisher? { 151 | guard autoScroll.isActive else { 152 | return nil 153 | } 154 | return Timer.publish(every: 1, on: .main, in: .common).autoconnect() 155 | } 156 | 157 | /// Defines the scaling based on whether the item is currently active or not. 158 | /// - Parameter item: The incoming item 159 | /// - Returns: scaling 160 | func itemScaling(_ item: Data.Element) -> CGFloat { 161 | guard activeIndex < data.count else { 162 | return 0 163 | } 164 | let activeItem = data[activeIndex as! Data.Index] 165 | return activeItem[keyPath: _dataId] == item[keyPath: _dataId] ? 1 : sidesScaling 166 | } 167 | } 168 | 169 | // MARK: - private variable 170 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 171 | extension SlideshowViewModel { 172 | 173 | private var isWrap: Bool { 174 | return _data.count > 1 ? _isWrap : false 175 | } 176 | 177 | private var autoScroll: SlideshowAutoScroll { 178 | guard _data.count > 1 else { return .inactive } 179 | guard case let .active(t) = _autoScroll else { return _autoScroll } 180 | return t > 0 ? _autoScroll : .defaultActive 181 | } 182 | 183 | private var defaultPadding: CGFloat { 184 | return (_headspace + spacing) 185 | } 186 | 187 | private var itemActualWidth: CGFloat { 188 | itemWidth + spacing 189 | } 190 | 191 | private var sidesScaling: CGFloat { 192 | return max(min(_sidesScaling, 1), 0) 193 | } 194 | 195 | /// Is animated when view is in offset 196 | private var isAnimatedOffset: Bool { 197 | get { UserDefaults.isAnimatedOffset } 198 | set { UserDefaults.isAnimatedOffset = newValue } 199 | } 200 | } 201 | 202 | // MARK: - Offset Method 203 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 204 | extension SlideshowViewModel { 205 | /// current offset value 206 | var offset: CGFloat { 207 | let activeOffset = CGFloat(activeIndex) * itemActualWidth 208 | return defaultPadding - activeOffset + dragOffset 209 | } 210 | 211 | /// change offset when acitveItem changes 212 | private func changeOffset() { 213 | isAnimatedOffset = true 214 | guard isWrap else { 215 | return 216 | } 217 | 218 | let minimumOffset = defaultPadding 219 | let maxinumOffset = defaultPadding - CGFloat(data.count - 1) * itemActualWidth 220 | 221 | if offset == minimumOffset { 222 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 223 | self.activeIndex = self.data.count - 2 224 | self.isAnimatedOffset = false 225 | } 226 | } else if offset == maxinumOffset { 227 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 228 | self.activeIndex = 1 229 | self.isAnimatedOffset = false 230 | } 231 | } 232 | } 233 | } 234 | 235 | // MARK: - Drag Gesture 236 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 237 | extension SlideshowViewModel { 238 | /// drag gesture of view 239 | var dragGesture: some Gesture { 240 | DragGesture() 241 | .onChanged(dragChanged) 242 | .onEnded(dragEnded) 243 | } 244 | 245 | private func dragChanged(_ value: DragGesture.Value) { 246 | guard _canMove else { return } 247 | 248 | isAnimatedOffset = true 249 | 250 | /// Defines the maximum value of the drag 251 | /// Avoid dragging more than the values of multiple subviews at the end of the drag, 252 | /// and still only one subview is toggled 253 | var offset: CGFloat = itemActualWidth 254 | if value.translation.width > 0 { 255 | offset = min(offset, value.translation.width) 256 | } else { 257 | offset = max(-offset, value.translation.width) 258 | } 259 | 260 | /// set drag offset 261 | dragOffset = offset 262 | 263 | /// stop active timer 264 | isTimerActive = false 265 | } 266 | 267 | private func dragEnded(_ value: DragGesture.Value) { 268 | guard _canMove else { return } 269 | /// reset drag offset 270 | dragOffset = .zero 271 | 272 | /// reset timing and restart active timer 273 | resetTiming() 274 | isTimerActive = true 275 | 276 | /// Defines the drag threshold 277 | /// At the end of the drag, if the drag value exceeds the drag threshold, 278 | /// the active view will be toggled 279 | /// default is one third of subview 280 | let dragThreshold: CGFloat = itemWidth / 3 281 | 282 | var activeIndex = self.activeIndex 283 | if value.translation.width > dragThreshold { 284 | activeIndex -= 1 285 | } 286 | if value.translation.width < -dragThreshold { 287 | activeIndex += 1 288 | } 289 | self.activeIndex = max(0, min(activeIndex, data.count - 1)) 290 | } 291 | } 292 | 293 | // MARK: - Receive Timer 294 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 295 | extension SlideshowViewModel { 296 | 297 | /// timer change 298 | func receiveTimer(_ value: Timer.TimerPublisher.Output) { 299 | /// Ignores listen when `isTimerActive` is false. 300 | guard isTimerActive else { 301 | return 302 | } 303 | /// increments of one and compare to the scrolling duration 304 | /// return when timing less than duration 305 | activeTiming() 306 | timing += 1 307 | if timing < autoScroll.interval { 308 | return 309 | } 310 | 311 | if activeIndex == data.count - 1 { 312 | /// `isWrap` is false. 313 | /// Revert to the first view after scrolling to the last view 314 | activeIndex = 0 315 | } else { 316 | /// `isWrap` is true. 317 | /// Incremental, calculation of offset by `offsetChanged(_: proxy:)` 318 | activeIndex += 1 319 | } 320 | resetTiming() 321 | } 322 | 323 | 324 | /// reset counting of time 325 | private func resetTiming() { 326 | timing = 0 327 | } 328 | 329 | /// time increments of one 330 | private func activeTiming() { 331 | timing += 1 332 | } 333 | } 334 | --------------------------------------------------------------------------------