// // 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: - 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 } } } /// Space between each tile. The default value will be 24px (6X) in tablet and 12px (3X) in mobile. public enum Gutter: String, CaseIterable { case twelvePX = "12px" case twentyFourPX = "24px" var value: CGFloat { switch self { case .twelvePX: VDSLayout.space3X case .twentyFourPX: VDSLayout.space6X } } } /// Enum used to describe the pagination display for this component. public enum PaginationDisplay: String, CaseIterable { case persistent, none } /// Enum used to describe the peek for this component. /// This is how much a tile is partially visible. It is measured by the distance between the edge of the tile and the edge of the viewport or carousel container. A peek can appear on the left and/or right edge of the carousel container or viewport, depending on the carousel’s scroll position. 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 } /// Enum used to describe the width of a fixed value or percentage of parent's width. public enum Width { case percentage(CGFloat) case value(CGFloat) } //-------------------------------------------------- // 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() } } /// If provided, width of slots will be rendered based on this value. If omitted, default widths are rendered. open var width : Width? { get { _width } set { if let newValue { switch newValue { case .percentage(let percentage): if percentage >= 10 && percentage <= 100.0 { _width = newValue } case .value(let value): if value > minimumSlotWidth { /*(size.minimumSlotWidth)*/ _width = newValue } } } else { _width = nil } setNeedsUpdate() } } /// Space between each tile. The default value will be 24px (6X) in tablet and 12px (3X) 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: CarouselScrollbar.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: - 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.space3X : VDSLayout.space1X $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 } /// Previous button to show previous slide. private var previousButton = ButtonIcon().with { $0.kind = .lowContrast $0.iconName = .leftCaret $0.iconOffset = .init(x: -2, y: 0) $0.icon.size = UIDevice.isIPad ? .small : .xsmall $0.size = UIDevice.isIPad ? .large : .small } /// Next button to show next slide. private var nextButton = ButtonIcon().with { $0.kind = .lowContrast $0.iconName = .rightCaret $0.iconOffset = .init(x: 2, y: 0) $0.icon.size = UIDevice.isIPad ? .small : .xsmall $0.size = UIDevice.isIPad ? .large : .small } /// A publisher for when the scrubber position changes. Passes parameters (position). open var onChangePublisher = PassthroughSubject() private var onChangeCancellable: AnyCancellable? internal var _layout: CarouselScrollbar.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.space3X : VDSLayout.space2X internal var _gutter: Gutter = UIDevice.isIPad ? .twentyFourPX : .twelvePX internal var _peek: Peek = .none internal var _numberOfSlides: Int = 1 private var _width: Width? = nil 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 scrollbarTopSpace = UIDevice.isIPad ? VDSLayout.space3X : VDSLayout.space1X var slotHeight = 100.0 var peekMinimum = 24.0 var minimumSlotWidth = 0.0 //-------------------------------------------------- // 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) scrollView.pinToSuperView() // add pagination button icons scrollContainerView.addSubview(previousButton) previousButton .pinLeading(paginationInset) .pinCenterY() scrollContainerView.addSubview(nextButton) nextButton .pinTrailing(paginationInset) .pinCenterY() // add scroll container view & carousel scrollbar contentStackView.addArrangedSubview(scrollContainerView) contentStackView.addArrangedSubview(carouselScrollBar) contentStackView.setCustomSpacing(scrollbarTopSpace, after: scrollContainerView) contentStackView .pinTop() .pinBottom() .pinLeading() .pinTrailing() .heightGreaterThanEqualTo(scrollbarTopSpace + containerSize.height) contentStackView.centerXAnchor.constraint(equalTo: centerXAnchor).activate() } open override func updateView() { super.updateView() carouselScrollBar.numberOfSlides = data.count carouselScrollBar.position = (carouselScrollBar.position == 0 || carouselScrollBar.position > carouselScrollBar.numberOfSlides) ? 1 : carouselScrollBar.position carouselScrollBar.layout = _layout carouselScrollBar.isHidden = (Int((Float(carouselScrollBar.numberOfSlides)) * Float(carouselScrollBar._layout.value)) <= 1) ? true : false // When peek is set to ‘none,’ pagination controls are automatically set to persistent. if peek == .none { paginationDisplay = .persistent } containerViewHeightConstraint?.isActive = false containerStackHeightConstraint?.isActive = false updatePaginationControls() getSlotWidth() // perform a loop to iterate each subView scrollView.subviews.forEach { subView in // removing subView from its parent view subView.removeFromSuperview() } // add carousel items if data.count > 0 { var xPos = 0.0 for x in 0...data.count - 1 { let carouselSlot = View().with { $0.clipsToBounds = true $0.backgroundColor = UIColor(red: CGFloat(216) / 255.0, green: CGFloat(218) / 255.0, blue: CGFloat(218) / 255.0, alpha: 1) } scrollView.addSubview(carouselSlot) carouselSlot .pinTop() .pinBottom() .pinLeading(xPos) .width(minimumSlotWidth) .height(slotHeight) xPos = xPos + minimumSlotWidth + gutter.value } scrollView.heightAnchor.constraint(equalToConstant: slotHeight).isActive = true scrollView.contentSize = CGSize(width: xPos - gutter.value, height: slotHeight) } let containerHeight = slotHeight + scrollbarTopSpace + containerSize.height if carouselScrollBar.isHidden { containerStackHeightConstraint = contentStackView.heightAnchor.constraint(equalToConstant: slotHeight) containerViewHeightConstraint = containerView.heightAnchor.constraint(equalToConstant: slotHeight) } else { containerStackHeightConstraint = contentStackView.heightAnchor.constraint(equalToConstant: containerHeight) containerViewHeightConstraint = containerView.heightAnchor.constraint(equalToConstant: containerHeight) } containerViewHeightConstraint?.isActive = true containerStackHeightConstraint?.isActive = true addlisteners() layoutIfNeeded() } open override func reset() { for subview in subviews { for recognizer in subview.gestureRecognizers ?? [] { subview.removeGestureRecognizer(recognizer) } } super.reset() } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- func addlisteners() { nextButton.onClick = { _ in self.nextButtonClick() } previousButton.onClick = { _ in self.previousButtonClick() } //setup test page to show scrubber id was changed // carouselScrollBar.onScrubberDrag = { [weak self] scrubberId in // guard let self else { return } // updateScrollPosition(position: scrubberId, callbackText:"onScrubberDrag") // } /// will be called when the thumb move forward. carouselScrollBar.onMoveForward = { [weak self] scrubberId in guard let self else { return } updateScrollPosition(position: scrubberId, callbackText:"onMoveForward") } /// will be called when the thumb move backward. carouselScrollBar.onMoveBackward = { [weak self] scrubberId in guard let self else { return } updateScrollPosition(position: scrubberId, callbackText:"onMoveBackward") } /// will be called when the thumb touch start. carouselScrollBar.onThumbTouchStart = { [weak self] scrubberId in guard let self else { return } updateScrollPosition(position: scrubberId, callbackText:"onThumbTouchStart") } /// will be called when the thumb touch end. carouselScrollBar.onThumbTouchEnd = { [weak self] scrubberId in guard let self else { return } updateScrollPosition(position: scrubberId, callbackText:"onThumbTouchEnd") } } func updatePaginationControls() { containerView.surface = surface showPaginationControls() previousButton.kind = pagination.kind previousButton.floating = pagination.floating nextButton.kind = pagination.kind nextButton.floating = pagination.floating previousButton.surface = surface nextButton.surface = surface } func getSlotWidth() { let actualWidth = containerView.frame.size.width minimumSlotWidth = actualWidth - (CGFloat(layout.value) * gutter.value) switch peek { case .standard: minimumSlotWidth = minimumSlotWidth - (3*peekMinimum) case .minimum: minimumSlotWidth = minimumSlotWidth - peekMinimum case .none: break } minimumSlotWidth = minimumSlotWidth / CGFloat(layout.value) } func nextButtonClick() { carouselScrollBar.position = carouselScrollBar.position+1 showPaginationControls() updateScrollPosition(position: carouselScrollBar.position, callbackText:"pageControlClicks") } func previousButtonClick() { carouselScrollBar.position = carouselScrollBar.position-1 showPaginationControls() updateScrollPosition(position: carouselScrollBar.position, callbackText:"pageControlClicks") } func updateScrollPosition(position: Int, callbackText: String) { if carouselScrollBar.numberOfSlides > 0 { let contentOffsetWidth = scrollView.contentSize.width let totalPositions = totalPositions() let multiplier: Float = (position == 1) ? 0 : Float((position)-1) / Float(totalPositions) let xPos = (position == totalPositions) ? (contentOffsetWidth - containerView.frame.size.width) : CGFloat(Float(contentOffsetWidth) * multiplier) carouselScrollBar.scrubberId = position let yPos = scrollView.contentOffset.y scrollView.setContentOffset(CGPoint(x: xPos, y: yPos), animated: true) showPaginationControls() } } private func totalPositions() -> Int { return Int (ceil (Double(carouselScrollBar.numberOfSlides) / Double(_layout.value))) } func showPaginationControls() { if carouselScrollBar.numberOfSlides == _layout.value { previousButton.isHidden = true nextButton.isHidden = true } else { previousButton.isHidden = (carouselScrollBar.position == 1) || (paginationDisplay == .none) nextButton.isHidden = (carouselScrollBar.position == totalPositions()) || (paginationDisplay == .none) } } }