323 lines
11 KiB
Swift
323 lines
11 KiB
Swift
//
|
|
// Carousel.swift
|
|
// VDS
|
|
//
|
|
// Created by Kanamarlapudi, Vasavi on 29/05/24.
|
|
//
|
|
|
|
import Foundation
|
|
import UIKit
|
|
import VDSTokens
|
|
import Combine
|
|
|
|
/// A carousel is a collection of related content in a row that a customer can navigate through horizontally.
|
|
/// Use this component to show content that is supplementary, not essential for task completion.
|
|
@objc(VDSCarousel)
|
|
open class Carousel: View {
|
|
|
|
//--------------------------------------------------
|
|
// MARK: - Initializers
|
|
//--------------------------------------------------
|
|
required public init() {
|
|
super.init(frame: .zero)
|
|
}
|
|
|
|
public override init(frame: CGRect) {
|
|
super.init(frame: .zero)
|
|
}
|
|
|
|
public required init?(coder: NSCoder) {
|
|
super.init(coder: coder)
|
|
}
|
|
|
|
//--------------------------------------------------
|
|
// MARK: - Public Properties
|
|
//--------------------------------------------------
|
|
/// Aspect-ratio options for tilelet in the carousel. If 'none' is passed, the tilelet will take the height of the tallest item in the carousel.
|
|
open var aspectRatio: AspectRatio = .ratio1x1 { didSet { setNeedsUpdate() } }
|
|
|
|
/// Data used to render tilelets in the carousel.
|
|
open var data: [Any] = [] { didSet { setNeedsUpdate() } }
|
|
|
|
/// Space between each tile. The default value will be 24px in tablet and 12px in mobile.
|
|
open var gutter: Gutter {
|
|
get { return _gutter }
|
|
set {
|
|
_gutter = newValue
|
|
setNeedsUpdate()
|
|
}
|
|
}
|
|
|
|
/// The amount of slides visible in the carousel container at one time. The default value will be 3UP in tablet and 1UP in mobile.
|
|
open var layout: Layout {
|
|
get { return _layout }
|
|
set {
|
|
_layout = newValue
|
|
setNeedsUpdate()
|
|
}
|
|
}
|
|
|
|
/// A callback when moving the carousel. Returns event object and selectedGroupIndex.
|
|
open var onChange: ((Int) -> Void)? { // TO DO: return object and index
|
|
get { nil }
|
|
set {
|
|
onChangeCancellable?.cancel()
|
|
if let newValue {
|
|
onChangeCancellable = onChangePublisher
|
|
.sink { c in
|
|
newValue(c)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Config object for pagination.
|
|
open var pagination: CarouselPaginationModel {
|
|
get { return _pagination }
|
|
set {
|
|
_pagination = newValue
|
|
setNeedsUpdate()
|
|
}
|
|
}
|
|
|
|
/// If provided, will determine the conditions to render the pagination arrows.
|
|
open var paginationDisplay: PaginationDisplay {
|
|
get { return _paginationDisplay }
|
|
set {
|
|
_paginationDisplay = newValue
|
|
setNeedsUpdate()
|
|
}
|
|
}
|
|
|
|
/// If provided, will apply margin to pagination arrows. Can be set to either positive or negative values.
|
|
/// The default value will be 12px in tablet and 8px in mobile.
|
|
open var paginationInset: CGFloat {
|
|
get { return _paginationInset }
|
|
set {
|
|
_paginationInset = newValue
|
|
setNeedsUpdate()
|
|
}
|
|
}
|
|
|
|
/// Options for user to configure the partially-visible tile in group. Setting peek to 'none' will display arrow navigation icons on mobile devices.
|
|
open var peek: Peek {
|
|
get { return _peek }
|
|
set {
|
|
_peek = newValue
|
|
setNeedsUpdate()
|
|
}
|
|
}
|
|
|
|
/// The initial visible slide's index in the carousel.
|
|
open var selectedIndex: Int? { didSet { setNeedsUpdate() } }
|
|
|
|
/// If provided, will set the alignment for slot content when the slots has different heights.
|
|
open var slotAlignment: [CarouselSlotAlignmentModel] = [] { didSet { setNeedsUpdate() } }
|
|
|
|
//--------------------------------------------------
|
|
// MARK: - Enums
|
|
//--------------------------------------------------
|
|
/// Enum used to describe the aspect ratios used for this component.
|
|
public enum AspectRatio: String, CaseIterable {
|
|
case ratio1x1 = "1:1"
|
|
case ratio3x4 = "3:4"
|
|
case ratio4x3 = "4:3"
|
|
case ratio2x3 = "2:3"
|
|
case ratio3x2 = "3:2"
|
|
case ratio9x16 = "9:16"
|
|
case ratio16x9 = "16:9"
|
|
case ratio1x2 = "1:2"
|
|
case ratio2x1 = "2:1"
|
|
case none
|
|
}
|
|
|
|
/// Enum used to describe the number of slides visible in the carousel container at one time. The default value will be 3UP in tablet and 1UP in mobile.
|
|
public enum Layout: String, CaseIterable {
|
|
case oneUP = "1UP"
|
|
case twoUP = "2UP"
|
|
case threeUP = "3UP"
|
|
case fourUP = "4UP"
|
|
case fiveUP = "5UP"
|
|
case sixUP = "6UP"
|
|
|
|
var value: Int {
|
|
switch self {
|
|
case .oneUP:
|
|
1
|
|
case .twoUP:
|
|
2
|
|
case .threeUP:
|
|
3
|
|
case .fourUP:
|
|
4
|
|
case .fiveUP:
|
|
5
|
|
case .sixUP:
|
|
6
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Enum used to describe the number of slides visible in the carousel container at one time. The default value will be 3UP in tablet and 1UP in mobile.
|
|
public enum Gutter: String, CaseIterable {
|
|
case twelvePX = "12px"
|
|
case twentyFourPX = "24px"
|
|
|
|
var value: CGFloat {
|
|
switch self {
|
|
case .twelvePX:
|
|
VDSLayout.space12X
|
|
case .twentyFourPX:
|
|
VDSLayout.space24X
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Enum used to describe the pagination display for this component.
|
|
public enum PaginationDisplay: String, CaseIterable {
|
|
case onHover, persistent, none
|
|
}
|
|
|
|
/// Enum used to describe the peek for this component. Options for user to configure the partially-visible tile in group. Setting peek to 'none' will display arrow navigation icons on mobile devices.
|
|
public enum Peek: String, CaseIterable {
|
|
case standard, minimum, none
|
|
}
|
|
|
|
// TO DO: move to model class
|
|
/// Enum used to describe the vertical of slotAlignment.
|
|
public enum Vertical: String, CaseIterable {
|
|
case top, middle, bottom
|
|
}
|
|
|
|
/// Enum used to describe the horizontal of slotAlignment.
|
|
public enum Horizontal: String, CaseIterable {
|
|
case left, center, right
|
|
}
|
|
|
|
//--------------------------------------------------
|
|
// MARK: - Private Properties
|
|
//--------------------------------------------------
|
|
// Sizes are from InVision design specs.
|
|
internal var containerSize: CGSize { CGSize(width: 320, height: 44) }
|
|
|
|
private let contentStackView = UIStackView().with {
|
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
$0.axis = .vertical
|
|
$0.distribution = .fill
|
|
$0.spacing = UIDevice.isIPad ? VDSLayout.space8X : VDSLayout.space6X
|
|
$0.backgroundColor = .clear
|
|
}
|
|
|
|
internal var carouselScrollBar = CarouselScrollbar().with {
|
|
$0.layout = UIDevice.isIPad ? .threeUP : .oneUP
|
|
$0.position = 0
|
|
$0.backgroundColor = .clear
|
|
}
|
|
|
|
internal var containerView = View().with {
|
|
$0.clipsToBounds = true
|
|
$0.backgroundColor = .clear
|
|
}
|
|
|
|
internal var scrollContainerView = View().with {
|
|
$0.clipsToBounds = true
|
|
$0.backgroundColor = .clear
|
|
}
|
|
|
|
private var scrollView = UIScrollView().with {
|
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
$0.backgroundColor = .clear
|
|
}
|
|
|
|
/// A publisher for when the scrubber position changes. Passes parameters (position).
|
|
open var onChangePublisher = PassthroughSubject<Int, Never>()
|
|
private var onChangeCancellable: AnyCancellable?
|
|
|
|
internal var _layout: Layout = UIDevice.isIPad ? .threeUP : .oneUP
|
|
internal var _pagination: CarouselPaginationModel = .init(kind: .lowContrast, floating: true)
|
|
internal var _paginationDisplay: PaginationDisplay = .none
|
|
internal var _paginationInset: CGFloat = UIDevice.isIPad ? VDSLayout.space12X : VDSLayout.space8X
|
|
internal var _gutter: Gutter = UIDevice.isIPad ? .twentyFourPX : .twelvePX
|
|
internal var _peek: Peek = .none
|
|
internal var _numberOfSlides: Int = 1
|
|
|
|
private var selectedGroupIndex: Int? { didSet { setNeedsUpdate() } }
|
|
|
|
private var containerStackHeightConstraint: NSLayoutConstraint?
|
|
private var containerViewHeightConstraint: NSLayoutConstraint?
|
|
|
|
// The scrollbar has top 5X space. So the expected top space is adjusted for tablet and mobile.
|
|
let space = UIDevice.isIPad ? VDSLayout.space3X : VDSLayout.space1X
|
|
|
|
//--------------------------------------------------
|
|
// MARK: - Lifecycle
|
|
//--------------------------------------------------
|
|
open override func initialSetup() {
|
|
super.initialSetup()
|
|
}
|
|
|
|
open override func setup() {
|
|
super.setup()
|
|
isAccessibilityElement = false
|
|
|
|
// add containerView
|
|
addSubview(containerView)
|
|
containerView
|
|
.pinTop()
|
|
.pinBottom()
|
|
.pinLeading()
|
|
.pinTrailing()
|
|
.heightGreaterThanEqualTo(containerSize.height)
|
|
containerView.centerXAnchor.constraint(equalTo: centerXAnchor).activate()
|
|
|
|
// add content stackview
|
|
containerView.addSubview(contentStackView)
|
|
|
|
// add scrollview
|
|
scrollContainerView.addSubview(scrollView)
|
|
|
|
// add pagination button icons
|
|
scrollContainerView.addSubview(pagination.previousButton)
|
|
pagination.previousButton
|
|
.pinLeading(paginationInset)
|
|
.pinCenterY()
|
|
|
|
scrollContainerView.addSubview(pagination.nextButton)
|
|
pagination.nextButton
|
|
.pinTrailing(paginationInset)
|
|
.pinCenterY()
|
|
|
|
// add scroll container view & carousel scrollbar
|
|
contentStackView.addArrangedSubview(scrollContainerView)
|
|
contentStackView.addArrangedSubview(carouselScrollBar)
|
|
contentStackView.setCustomSpacing(space, after: scrollContainerView)
|
|
contentStackView
|
|
.pinTop()
|
|
.pinBottom()
|
|
.pinLeading()
|
|
.pinTrailing()
|
|
.heightGreaterThanEqualTo(space+containerSize.height)
|
|
contentStackView.centerXAnchor.constraint(equalTo: centerXAnchor).activate()
|
|
}
|
|
|
|
open override func updateView() {
|
|
containerViewHeightConstraint?.isActive = false
|
|
super.updateView()
|
|
containerViewHeightConstraint = containerView.heightAnchor.constraint(equalToConstant: 200)
|
|
containerViewHeightConstraint?.isActive = true
|
|
|
|
pagination.previousButton.isHidden = (paginationDisplay == .none)
|
|
pagination.nextButton.isHidden = (paginationDisplay == .none)
|
|
layoutIfNeeded()
|
|
}
|
|
|
|
open override func reset() {
|
|
// for subview in subviews {
|
|
// for recognizer in subview.gestureRecognizers ?? [] {
|
|
// subview.removeGestureRecognizer(recognizer)
|
|
// }
|
|
// }
|
|
super.reset()
|
|
}
|
|
}
|