Last active
February 5, 2023 15:31
-
-
Save kirti-swiggy/20bf26f4ba48fcfb878720e335c064dd to your computer and use it in GitHub Desktop.
A horizontal carousel view in SwiftUI with automatic and manual scroll support. Created using TabView.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// OfferCarousalView.swift | |
// Playground | |
// | |
// Created by Kirti Kumar Verma on 04/01/23. | |
// | |
import Combine | |
import Kingfisher | |
import SwiftUI | |
@available(iOS 14.0, *) | |
public struct OfferCarousalView: View { | |
public struct OfferData { | |
let heading: String | |
let subheading: String | |
let imageURL: URL | |
let onTap: () -> Void | |
public init( | |
heading: String, | |
subheading: String, | |
imageURL: URL, | |
onTap: @escaping () -> Void | |
) { | |
self.heading = heading | |
self.subheading = subheading | |
self.imageURL = imageURL | |
self.onTap = onTap | |
} | |
} | |
// MARK: State properties | |
@State private var selection: Int = 0 | |
@State private var textBlockSize: CGSize? | |
// MARK: Theme | |
@ObservedObject private var themingObject: ThemeingObject | |
// MARK: Private properties | |
private let offers: [OfferData] | |
private let timer = Timer.publish(every: interval, on: .main, in: .default).autoconnect() | |
// MARK: - Public methods | |
public init( | |
themingObject: ThemeingObject, | |
offers: [OfferData] | |
) { | |
self.themingObject = themingObject | |
self.offers = offers | |
} | |
// MARK: - body | |
public var body: some View { | |
containerView | |
.background(Color(themingObject.color(for: \.backgroundPrimary).color)) | |
.cornerRadius(Tokens.CornerRadius.large.value) | |
.shadow( | |
color: Color(Tokens.Elevation.raised300.value.color), | |
radius: Tokens.Elevation.raised300.value.radius, | |
x: Tokens.Elevation.raised300.value.offset.width, | |
y: Tokens.Elevation.raised300.value.offset.height | |
) | |
} | |
// MARK: - Implementation details | |
private var containerView: some View { | |
HStack(spacing: Tokens.Spacing.small.value) { | |
TabView(selection: $selection) { | |
ForEach(0 ..< numberOfItems, id: \.self) { _ in | |
offersTextBlock | |
.overlay( | |
GeometryReader { proxy in | |
Color.clear | |
.preference(key: ViewRectKey.self, value: proxy.size) | |
} | |
) | |
.onPreferenceChange(ViewRectKey.self) { size in | |
textBlockSize = size | |
} | |
} | |
} | |
.frame(height: textBlockSize?.height) | |
.tabViewStyle(.page(indexDisplayMode: .never)) | |
indexDisplayView | |
} | |
.onReceive(timer) { _ in | |
withAnimation(.spring()) { | |
selection = (selection + 1) % numberOfItems | |
} | |
} | |
.padding(Tokens.Spacing.small.value) | |
.onTapGesture { | |
offers[selection].onTap() | |
} | |
} | |
private var offersTextBlock: some View { | |
HStack(spacing: Tokens.Spacing.xSmall.value) { | |
KFImage | |
.url(offers[selection].imageURL) | |
.resizable() | |
.aspectRatio(contentMode: .fit) | |
.frame(width: imageDimention, height: imageDimention) | |
VStack(alignment: .leading, spacing: Tokens.Spacing.xxSmall.value) { | |
TextView( | |
themeingObject: themingObject, | |
text: offers[selection].heading, | |
textStyle: .bodyB1Bold, | |
textColorToken: \.textHighEmphasis | |
) | |
TextView( | |
themeingObject: themingObject, | |
text: offers[selection].subheading, | |
textStyle: .bodyB2Reg, | |
textColorToken: \.textMedEmphasis | |
) | |
} | |
} | |
.frame(maxWidth: .infinity, alignment: .leading) | |
} | |
private var indexDisplayView: some View { | |
VStack(spacing: Tokens.Spacing.xxSmall.value) { | |
TextView( | |
text: "\(selection + 1)/\(numberOfItems)", | |
textStyle: .bodyB2Bold, | |
textColorToken: \.primary | |
) | |
HStack { | |
ForEach(0 ..< numberOfDots, id: \.self) { index in | |
Circle() | |
.frame( | |
width: getDotInfo(for: index).diameter, | |
height: getDotInfo(for: index).diameter | |
) | |
.foregroundColor(getDotInfo(for: index).color) | |
} | |
} | |
} | |
} | |
private var numberOfItems: Int { | |
offers.count | |
} | |
private var numberOfDots: Int { | |
min(maxNumberOfDots, numberOfItems) | |
} | |
private func getDotInfo(for index: Int) -> (diameter: CGFloat, color: Color) { | |
var isSelected: Bool = false | |
// MARK: TODO simplify the logic | |
if index == 0, selection == 0 { | |
isSelected = true | |
} else if index == numberOfDots - 1, selection == numberOfItems - 1 { | |
isSelected = true | |
} else if index == 1, selection > 0, selection < numberOfItems - 1 { | |
isSelected = true | |
} | |
if isSelected { | |
return (selectedDotDiameter, Color(colorToken: themingObject.color(for: \.primary).color)) | |
} else { | |
return (unselectedDotDiameter, Color(colorToken: themingObject.color(for: \.borderSecondary).color)) | |
} | |
} | |
private struct ViewRectKey: PreferenceKey { | |
static let defaultValue: CGSize? = nil | |
static func reduce(value: inout CGSize?, nextValue: () -> CGSize?) { | |
value = value ?? nextValue() | |
} | |
} | |
} | |
// MARK: Constants | |
private let maxNumberOfDots: Int = 3 | |
private let interval: TimeInterval = 3 | |
private let imageDimention: CGFloat = 48 | |
private let selectedDotDiameter: CGFloat = 6 | |
private let unselectedDotDiameter: CGFloat = 4 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment