Skip to content

Commit b12e323

Browse files
committed
Initial commit
0 parents  commit b12e323

File tree

6 files changed

+271
-0
lines changed

6 files changed

+271
-0
lines changed

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
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
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>IDEDidComputeMac32BitWarning</key>
6+
<true/>
7+
</dict>
8+
</plist>

Package.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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+
)

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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+
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+
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
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<Content: View, T: Any>: 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+
private let data: [T]
19+
private let seconds: Double
20+
private let content: (T) -> Content
21+
private let showAlternativeBanner: Bool
22+
private let height: CGFloat
23+
private let horizontalPadding: CGFloat
24+
private let cornerRadius: CGFloat
25+
private let transition: TransitionType
26+
27+
// MARK: Init
28+
public init(data: [T], secondsDisplayingBanner: Double = 3, height: CGFloat = 150, horizontalPadding: CGFloat = 30, cornerRadius: CGFloat = 10, transition: TransitionType = .scale, @ViewBuilder content: @escaping (T) -> Content) {
29+
// We repeat the first and last element and add them to the data array. So we have something like this:
30+
// [item 4, item 1, item 2, item 3, item 4, item 1]
31+
var modifiedData = data
32+
if let firstElement = data.first, let lastElement = data.last {
33+
modifiedData.append(firstElement)
34+
modifiedData.insert(lastElement, at: 0)
35+
showAlternativeBanner = false
36+
} else {
37+
showAlternativeBanner = true
38+
}
39+
self._timer = .init(initialValue: Timer.publish(every: secondsDisplayingBanner, on: .main, in: .common))
40+
self.data = modifiedData
41+
self.content = content
42+
self.seconds = secondsDisplayingBanner
43+
self.height = height
44+
self.horizontalPadding = horizontalPadding
45+
self.cornerRadius = cornerRadius
46+
self.transition = transition
47+
}
48+
49+
public var body: some View {
50+
TabView(selection: $selectedTab) {
51+
/*
52+
The data passed to ForEach is an array ([T]), but the actually data ForEach procesess is an array of tuples: [(1, data1),(2, data2), ...].
53+
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.
54+
*/
55+
ForEach(Array(zip(data.indices, data)), id: \.0) { index, item in
56+
GeometryReader { proxy in
57+
let positionMinX = proxy.frame(in: .global).minX
58+
59+
content(item)
60+
.cornerRadius(cornerRadius)
61+
.frame(maxWidth: .infinity, maxHeight: .infinity)
62+
.rotation3DEffect(transition == .rotation3D ? getRotation(positionMinX) : .degrees(0), axis: (x: 0, y: 1, z: 0))
63+
.opacity(transition == .opacity ? getValue(positionMinX) : 1)
64+
.scaleEffect(transition == .scale ? getValue(positionMinX) : 1)
65+
.padding(.horizontal, horizontalPadding)
66+
.onChange(of: positionMinX) { offset in
67+
// If the user change the position of a banner, the offset is different of 0, so we stop the timer
68+
if offset != 0 {
69+
stopTimer()
70+
}
71+
// When the banner returns to its initial position (user drops the banner), start the timer again
72+
if offset == 0 {
73+
startTimer()
74+
}
75+
}
76+
}
77+
.tag(index)
78+
}
79+
}
80+
.tabViewStyle(.page(indexDisplayMode: .never))
81+
.frame(height: height)
82+
.onChange(of: selectedTab) { newValue in
83+
if showAlternativeBanner {
84+
guard newValue < data.count else {
85+
withAnimation {
86+
selectedTab = 0
87+
}
88+
return
89+
}
90+
} else {
91+
// 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)
92+
if newValue == 0 {
93+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
94+
selectedTab = data.count - 2
95+
}
96+
}
97+
98+
// 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)
99+
if newValue == data.count - 1 {
100+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
101+
selectedTab = 1
102+
}
103+
}
104+
}
105+
}
106+
.onAppear {
107+
startTimer()
108+
}
109+
.onReceive(timer) { _ in
110+
withAnimation {
111+
selectedTab += 1
112+
}
113+
}
114+
.onChange(of: scenePhase) { newValue in
115+
switch newValue {
116+
case .active:
117+
startTimer()
118+
case .background, .inactive:
119+
stopTimer()
120+
default:
121+
break
122+
}
123+
124+
}
125+
}
126+
}
127+
128+
// Helpers functions
129+
extension InfiniteCarousel {
130+
131+
// Get rotation for rotation3DEffect modifier
132+
private func getRotation(_ positionX: CGFloat) -> Angle {
133+
return .degrees(positionX / -10)
134+
}
135+
136+
// Get the value for scale and opacity modifiers
137+
private func getValue(_ positionX: CGFloat) -> CGFloat {
138+
let scale = 1 - abs(positionX / UIScreen.main.bounds.width)
139+
return scale
140+
}
141+
142+
private func startTimer() {
143+
guard cancellable == nil else {
144+
return
145+
}
146+
timer = Timer.publish(every: seconds, on: .main, in: .common)
147+
cancellable = timer.connect()
148+
}
149+
150+
private func stopTimer() {
151+
guard cancellable != nil else {
152+
return
153+
}
154+
cancellable?.cancel()
155+
cancellable = nil
156+
}
157+
}
158+
159+
public enum TransitionType {
160+
case rotation3D, scale, opacity
161+
}
162+
163+
struct InfiniteCarousel_Previews: PreviewProvider {
164+
static var previews: some View {
165+
InfiniteCarousel(data: ["Element 1", "Element 2", "Element 3", "Element 4"]) { element in
166+
Text(element)
167+
.font(.title.bold())
168+
.padding()
169+
.background(Color.green)
170+
}
171+
}
172+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
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+
}

0 commit comments

Comments
 (0)