// // Carousel.swift // VDS // // Created by Kanamarlapudi, Vasavi on 29/05/24. // import Foundation import UIKit import VDSCoreTokens 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 //-------------------------------------------------- /// 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 } /// 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: Tilelet.AspectRatio = .none { 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 { let expectedWidth = safeAreaLayoutGuide.layoutFrame.size.width * (percentage/100) if expectedWidth > carouselScrollbarMinWidth { _width = newValue } } case .value(let value): if value > carouselScrollbarMinWidth { _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)? { 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. These values are the default in order to avoid overlapping content within the carousel. open var paginationInset: CGFloat { get { return _paginationInset } set { _paginationInset = newValue updatePaginationInset() } } /// 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.customContainerSize = UIDevice.isIPad ? 40 : 28 $0.icon.customSize = UIDevice.isIPad ? 16 : 12 } /// 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.customContainerSize = UIDevice.isIPad ? 40 : 28 $0.icon.customSize = UIDevice.isIPad ? 16 : 12 } /// A publisher for when the scrubber position changes. Passes parameters (position). open var onChangePublisher = PassthroughSubject() private var onChangeCancellable: AnyCancellable? /// A publisher for when the carousel moves. Passes parameters (data). open var onScrollPublisher = PassthroughSubject, Never>() private var onScrollCancellable: 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 = .standard internal var _numberOfSlides: Int = 1 private var _width: Width? = nil private var selectedGroupIndex: Int? { didSet { setNeedsUpdate() } } private var containerStackHeightConstraint: NSLayoutConstraint? private var containerViewHeightConstraint: NSLayoutConstraint? private var prevButtonLeadingConstraint: NSLayoutConstraint? private var nextButtonTrailingConstraint: NSLayoutConstraint? private var containerLeadingConstraint: 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 var carouselScrollbarMinWidth = 96.0 //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- open override func initialSetup() { super.initialSetup() } open override func setup() { super.setup() isAccessibilityElement = false // add containerView addSubview(containerView) containerView .pinTop() .pinBottom() .pinLeadingGreaterThanOrEqualTo() .pinTrailing() .heightGreaterThanEqualTo(containerSize.height) containerView.centerYAnchor.constraint(equalTo: centerYAnchor).activate() containerLeadingConstraint = containerView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 0) containerLeadingConstraint?.activate() // add content stackview containerView.addSubview(contentStackView) // add scrollview scrollContainerView.addSubview(scrollView) scrollView.pinToSuperView() // add pagination button icons scrollContainerView.addSubview(previousButton) previousButton .pinLeadingGreaterThanOrEqualTo() .pinCenterY() scrollContainerView.addSubview(nextButton) nextButton .pinTrailingLessThanOrEqualTo() .pinCenterY() // add scroll container view & carousel scrollbar contentStackView.addArrangedSubview(scrollContainerView) contentStackView.addArrangedSubview(carouselScrollBar) contentStackView.setCustomSpacing(scrollbarTopSpace, after: scrollContainerView) contentStackView .pinTop() .pinBottom() .pinLeading() .pinTrailing() .heightGreaterThanEqualTo(containerSize.height) addlisteners() } open override func updateView() { super.updateView() if containerView.frame.size.width > 0 { if let width { containerLeadingConstraint?.deactivate() switch width { case .value(let value): var expectedWidth = value let fullWidth = safeAreaLayoutGuide.layoutFrame.size.width expectedWidth = expectedWidth > fullWidth ? fullWidth : expectedWidth containerLeadingConstraint?.constant = safeAreaLayoutGuide.layoutFrame.size.width - expectedWidth case .percentage(let percentage): let expectedWidth = safeAreaLayoutGuide.layoutFrame.size.width * (percentage/100) containerLeadingConstraint?.constant = safeAreaLayoutGuide.layoutFrame.size.width - expectedWidth } containerLeadingConstraint?.activate() } } carouselScrollBar.numberOfSlides = data.count carouselScrollBar.layout = _layout carouselScrollBar.position = (carouselScrollBar.position == 0 || carouselScrollBar.position > carouselScrollBar.numberOfSlides) ? 1 : carouselScrollBar.position carouselScrollBar.isHidden = (totalPositions() <= 1) ? true : false // Mobile/Tablet layouts without peek - must show pagination controls. // If peek is ‘none’, pagination controls should show. So set to persistent. if peek == .none { paginationDisplay = .persistent } // Minimum (Mobile only) Supported only on Mobile viewports. If a user passes Minimum for tablet carousel, the peek reverts to Standard. if UIDevice.isIPad && peek == .minimum { peek = .standard } // Standard(Default) Peek - Supported for all Tablet viewports and layouts. Supported only for 1up layouts on Mobile viewports. if peek == .standard && !UIDevice.isIPad && layout != CarouselScrollbar.Layout.oneUP { peek = .minimum } updatePaginationControls() getSlotWidth() addCarouselSlots() } open override func reset() { super.reset() shouldUpdateView = false aspectRatio = .none layout = UIDevice.isIPad ? .threeUP : .oneUP pagination = .init(kind: .lowContrast, floating: true) paginationDisplay = .none paginationInset = UIDevice.isIPad ? VDSLayout.space3X : VDSLayout.space2X gutter = UIDevice.isIPad ? .twentyFourPX : .twelvePX peek = .standard width = nil } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- private func addCarouselSlots() { if containerView.frame.size.width > 0 { containerViewHeightConstraint?.isActive = false containerStackHeightConstraint?.isActive = false // 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) let size = ratioSize(for: minimumSlotWidth) slotHeight = size.height carouselSlot .pinTop() .pinBottom() .pinLeading(xPos) .width(minimumSlotWidth) .height(slotHeight) carouselSlot.layer.cornerRadius = 12.0 xPos = xPos + minimumSlotWidth + gutter.value } 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 } } private func ratioSize(for width: CGFloat) -> CGSize { var height: CGFloat = width switch aspectRatio { case .ratio1x1: break; case .ratio3x4: height = (4 / 3) * width case .ratio4x3: height = (3 / 4) * width case .ratio2x3: height = (3 / 2) * width case .ratio3x2: height = (2 / 3) * width case .ratio9x16: height = (16 / 9) * width case .ratio16x9: height = (9 / 16) * width case .ratio1x2: height = (2 / 1) * width case .ratio2x1: height = (1 / 2) * width default: break } return CGSize(width: width, height: height) } func addlisteners() { nextButton.onClick = { _ in self.nextButtonClick() } previousButton.onClick = { _ in self.previousButtonClick() } /// 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 updatePaginationInset() { prevButtonLeadingConstraint?.isActive = false nextButtonTrailingConstraint?.isActive = false prevButtonLeadingConstraint = previousButton.leadingAnchor.constraint(equalTo: scrollContainerView.leadingAnchor, constant: paginationInset) nextButtonTrailingConstraint = nextButton.trailingAnchor.constraint(equalTo: scrollContainerView.trailingAnchor, constant: -paginationInset) prevButtonLeadingConstraint?.isActive = true nextButtonTrailingConstraint?.isActive = true } func getSlotWidth() { let actualWidth = containerView.frame.size.width let isScrollbarSuppressed = data.count > 0 && layout.value == data.count let isPeekMinimumOnTablet = UIDevice.isIPad && peek == .minimum let isPeekNone: Bool = peek == .none minimumSlotWidth = isScrollbarSuppressed || isPeekMinimumOnTablet || isPeekNone ? actualWidth - ((CGFloat(layout.value)-1) * gutter.value): actualWidth - (CGFloat(layout.value) * gutter.value) if !isScrollbarSuppressed { switch peek { case .standard: // Standard(Default) Peek - Supported for all Tablet viewports and layouts. Supported only for 1up layouts on Mobile viewports. if UIDevice.isIPad { minimumSlotWidth = minimumSlotWidth - (minimumSlotWidth/(CGFloat(layout.value) + 3)) } else if layout == .oneUP { minimumSlotWidth = minimumSlotWidth - (minimumSlotWidth/4) } case .minimum: // Peek Mimumum Width: 24px from edge of container (at the default view of the carousel with one peek visible) // Minimum (Mobile only) Supported only on Mobile viewports. If a user passes Minimum for tablet carousel, the peek reverts to Standard. minimumSlotWidth = isPeekMinimumOnTablet ? 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 scrollContentSizeWidth = scrollView.contentSize.width let totalPositions = totalPositions() let multiplier: Float = (position == 1) ? 0 : Float((position)-1) / Float(totalPositions) let xPos = (position == totalPositions) ? (scrollContentSizeWidth - containerView.frame.size.width) : CGFloat(Float(scrollContentSizeWidth) * 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) } } }