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