|
| 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 | +} |
0 commit comments