├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── SwiftUIInfiniteCarousel │ ├── InfiniteCarousel.swift │ └── ViewLifeCycleHandler.swift └── Tests └── SwiftUIInfiniteCarouselTests └── SwiftUIInfiniteCarouselTests.swift /.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 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Daniel Carvajal 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 | -------------------------------------------------------------------------------- /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: "SwiftUIInfiniteCarousel", 8 | platforms: [.iOS(.v14)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, and make them visible to other packages. 11 | .library( 12 | name: "SwiftUIInfiniteCarousel", 13 | targets: ["SwiftUIInfiniteCarousel"]), 14 | ], 15 | dependencies: [ 16 | // Dependencies declare other packages that this package depends on. 17 | // .package(url: /* package url */, from: "1.0.0"), 18 | ], 19 | targets: [ 20 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 21 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 22 | .target( 23 | name: "SwiftUIInfiniteCarousel", 24 | dependencies: []), 25 | .testTarget( 26 | name: "SwiftUIInfiniteCarouselTests", 27 | dependencies: ["SwiftUIInfiniteCarousel"]), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUI Infinite Carousel 2 | 3 | 4 | 5 | An infinite caurrsel made for SwiftUI, compatible with iOS 14+. Easy to use and customizable. 6 | 7 | https://user-images.githubusercontent.com/71818002/183007353-7f6cce02-1c81-40c8-a3fd-e64c194742fc.mp4 8 | 9 | ## Features 10 | 11 | - Infinite elements 12 | - Custom transitions beetwen pages 13 | - Timer to display a number of seconds per element 14 | - iOS +14 compatibility 15 | 16 | ## Install 17 | 18 | ### Swift Package Manager 19 | 20 | ``` 21 | https://github.com/dancarvajc/SwiftUIInfiniteCarousel.git 22 | ``` 23 | 24 | ## Example 25 | 26 | ```swift 27 | let elements: [String] = ["Data 1","Data 2","Data 3","Data 4"] 28 | var body: some View { 29 | ZStack { 30 | Color(red: 0/255, green: 67/255, blue: 105/255) 31 | .ignoresSafeArea() 32 | InfiniteCarousel(data: elements, height: 300, cornerRadius: 15, transition: .scale) { element in 33 | Text(element) 34 | .font(.title.bold()) 35 | .foregroundColor(Color(red: 1/255, green: 148/255, blue: 154/255)) 36 | .frame(maxWidth: .infinity, maxHeight: .infinity) 37 | .background( Color(red: 229/255, green: 221/255, blue: 200/255)) 38 | } 39 | } 40 | } 41 | ``` 42 | 43 | -------------------------------------------------------------------------------- /Sources/SwiftUIInfiniteCarousel/InfiniteCarousel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InfiniteCarousel.swift 3 | // 4 | // 5 | // Created by Daniel Carvajal on 01-08-22. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | public struct InfiniteCarousel: View { 12 | 13 | // MARK: Properties 14 | @Environment(\.scenePhase) var scenePhase 15 | @State private var timer: Timer.TimerPublisher 16 | @State private var cancellable: Cancellable? 17 | @State private var selectedTab: Int = 1 18 | @State private var isScaleEnabled: Bool = true 19 | private let data: [T] 20 | private let seconds: Double 21 | private let content: (T) -> Content 22 | private let showAlternativeBanner: Bool 23 | private let height: CGFloat 24 | private let horizontalPadding: CGFloat 25 | private let cornerRadius: CGFloat 26 | private let transition: TransitionType 27 | 28 | // MARK: Init 29 | public init(data: [T], secondsDisplayingBanner: Double = 3, height: CGFloat = 150, horizontalPadding: CGFloat = 30, cornerRadius: CGFloat = 10, transition: TransitionType = .scale, @ViewBuilder content: @escaping (T) -> Content) { 30 | // We repeat the first and last element and add them to the data array. So we have something like this: 31 | // [item 4, item 1, item 2, item 3, item 4, item 1] 32 | var modifiedData = data 33 | if let firstElement = data.first, let lastElement = data.last { 34 | modifiedData.append(firstElement) 35 | modifiedData.insert(lastElement, at: 0) 36 | showAlternativeBanner = false 37 | } else { 38 | showAlternativeBanner = true 39 | } 40 | self._timer = .init(initialValue: Timer.publish(every: secondsDisplayingBanner, on: .main, in: .common)) 41 | self.data = modifiedData 42 | self.content = content 43 | self.seconds = secondsDisplayingBanner 44 | self.height = height 45 | self.horizontalPadding = horizontalPadding 46 | self.cornerRadius = cornerRadius 47 | self.transition = transition 48 | } 49 | 50 | public var body: some View { 51 | TabView(selection: $selectedTab) { 52 | /* 53 | The data passed to ForEach is an array ([T]), but the actually data ForEach procesess is an array of tuples: [(1, data1),(2, data2), ...]. 54 | With this, we have the data and its corresponding index, so we don't have the problem of the same id, because the real index for ForEach is using for identify the items is the index generated with the zip function. 55 | */ 56 | ForEach(Array(zip(data.indices, data)), id: \.0) { index, item in 57 | GeometryReader { proxy in 58 | let positionMinX = proxy.frame(in: .global).minX 59 | 60 | content(item) 61 | .cornerRadius(cornerRadius) 62 | .frame(maxWidth: .infinity, maxHeight: .infinity) 63 | .rotation3DEffect(transition == .rotation3D ? getRotation(positionMinX) : .degrees(0), axis: (x: 0, y: 1, z: 0)) 64 | .opacity(transition == .opacity ? getValue(positionMinX) : 1) 65 | .scaleEffect(isScaleEnabled && transition == .scale ? getValue(positionMinX) : 1) 66 | .padding(.horizontal, horizontalPadding) 67 | .onChange(of: positionMinX) { offset in 68 | // If the user change the position of a banner, the offset is different of 0, so we stop the timer 69 | if offset != 0 { 70 | stopTimer() 71 | } 72 | // When the banner returns to its initial position (user drops the banner), start the timer again 73 | if offset == 0 { 74 | startTimer() 75 | } 76 | } 77 | } 78 | .tag(index) 79 | } 80 | } 81 | .tabViewStyle(.page(indexDisplayMode: .never)) 82 | .frame(height: height) 83 | .onChange(of: selectedTab) { newValue in 84 | if showAlternativeBanner { 85 | guard newValue < data.count else { 86 | withAnimation { 87 | selectedTab = 0 88 | } 89 | return 90 | } 91 | } else { 92 | // If the index is the first item (which is the last one, but repeated) we assign the tab to the real item, no the repeated one) 93 | if newValue == 0 { 94 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { 95 | selectedTab = data.count - 2 96 | } 97 | } 98 | 99 | // If the index is the last item (which is the first one, but repeated) we assign the tab to the real item, no the repeated one) 100 | if newValue == data.count - 1 { 101 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { 102 | selectedTab = 1 103 | } 104 | } 105 | } 106 | } 107 | .onAppear { 108 | startTimer() 109 | isScaleEnabled = true 110 | } 111 | .onWillDisappear { 112 | isScaleEnabled = false 113 | } 114 | .onReceive(timer) { _ in 115 | withAnimation { 116 | selectedTab += 1 117 | } 118 | } 119 | .onChange(of: scenePhase) { newValue in 120 | switch newValue { 121 | case .active: 122 | startTimer() 123 | case .background, .inactive: 124 | stopTimer() 125 | default: 126 | break 127 | } 128 | 129 | } 130 | } 131 | } 132 | 133 | // Helpers functions 134 | extension InfiniteCarousel { 135 | 136 | // Get rotation for rotation3DEffect modifier 137 | private func getRotation(_ positionX: CGFloat) -> Angle { 138 | return .degrees(positionX / -10) 139 | } 140 | 141 | // Get the value for scale and opacity modifiers 142 | private func getValue(_ positionX: CGFloat) -> CGFloat { 143 | let scale = 1 - abs(positionX / UIScreen.main.bounds.width) 144 | return scale 145 | } 146 | 147 | private func startTimer() { 148 | guard cancellable == nil else { 149 | return 150 | } 151 | timer = Timer.publish(every: seconds, on: .main, in: .common) 152 | cancellable = timer.connect() 153 | } 154 | 155 | private func stopTimer() { 156 | guard cancellable != nil else { 157 | return 158 | } 159 | cancellable?.cancel() 160 | cancellable = nil 161 | } 162 | } 163 | 164 | public enum TransitionType { 165 | case rotation3D, scale, opacity 166 | } 167 | 168 | struct TestView: View { 169 | @State private var isActive: Bool = false 170 | var body: some View { 171 | NavigationView { 172 | VStack { 173 | InfiniteCarousel(data: ["Element 1", "Element 2", "Element 3", "Element 4"]) { element in 174 | Text(element) 175 | .font(.title.bold()) 176 | .padding() 177 | .background(Color.green) 178 | } 179 | NavigationLink(destination: Text("Next screen"), isActive: $isActive) { 180 | Button("Go to next screen") { 181 | isActive = true 182 | } 183 | } 184 | } 185 | } 186 | } 187 | } 188 | struct InfiniteCarousel_Previews: PreviewProvider { 189 | static var previews: some View { 190 | TestView() 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /Sources/SwiftUIInfiniteCarousel/ViewLifeCycleHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Daniel Carvajal on 22-10-22. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | // Monitors the life cycles of view (onWillAppear or onWillDisappear) 12 | private struct ViewLifeCycleHandler: UIViewControllerRepresentable { 13 | 14 | let onWillDisappear: () -> Void 15 | let onWillAppear: () -> Void 16 | 17 | func makeUIViewController(context: Context) -> UIViewController { 18 | ViewLifeCycleViewController(onWillDisappear: onWillDisappear, onWillAppear: onWillAppear) 19 | } 20 | 21 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} 22 | } 23 | 24 | private class ViewLifeCycleViewController: UIViewController { 25 | let onWillDisappear: () -> Void 26 | let onWillAppear: () -> Void 27 | 28 | init(onWillDisappear: @escaping () -> Void, onWillAppear: @escaping () -> Void) { 29 | self.onWillDisappear = onWillDisappear 30 | self.onWillAppear = onWillAppear 31 | super.init(nibName: nil, bundle: nil) 32 | } 33 | 34 | required init?(coder: NSCoder) { 35 | fatalError("init(coder:) has not been implemented") 36 | } 37 | 38 | override func viewWillDisappear(_ animated: Bool) { 39 | super.viewWillDisappear(animated) 40 | onWillDisappear() 41 | } 42 | override func viewWillAppear(_ animated: Bool) { 43 | super.viewWillAppear(animated) 44 | onWillAppear() 45 | } 46 | } 47 | 48 | extension View { 49 | func onWillDisappear(_ perform: @escaping () -> Void) -> some View { 50 | background(ViewLifeCycleHandler(onWillDisappear: perform, onWillAppear: {})) 51 | } 52 | func onWillAppear(_ perform: @escaping () -> Void) -> some View { 53 | background(ViewLifeCycleHandler(onWillDisappear: {}, onWillAppear: perform)) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Tests/SwiftUIInfiniteCarouselTests/SwiftUIInfiniteCarouselTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftUIInfiniteCarousel 3 | 4 | final class SwiftUIInfiniteCarouselTests: 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(SwiftUIInfiniteCarousel().text, "Hello, World!") 10 | // } 11 | } 12 | --------------------------------------------------------------------------------