// // 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. @objcMembers @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 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 } /// Space between each tile. The default value will be 6X in tablet and 3X in mobile. public enum Gutter: String, CaseIterable , DefaultValuing { case gutter3X = "3X" case gutter6X = "6X" public static var defaultValue: Self { UIDevice.isIPad ? .gutter6X : .gutter3X } public var value: CGFloat { switch self { case .gutter3X: VDSLayout.space3X case .gutter6X: VDSLayout.space6X } } } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- /// views used to render view in the carousel slots. open var views: [UIView] = [] { didSet { setNeedsUpdate() } } /// Space between each tile. The default value will be 6X in tablet and 3X in mobile. open var gutter: Gutter = Gutter.defaultValue { didSet { 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 = UIDevice.isIPad ? .threeUP : .oneUP { didSet { carouselScrollBar.position = 0 setNeedsUpdate() } } /// A callback when moving the carousel. Returns 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 = .init(kind: .lowContrast, floating: true) { didSet {setNeedsUpdate() } } /// If provided, will determine the conditions to render the pagination arrows. open var paginationDisplay: PaginationDisplay = .none { didSet {setNeedsUpdate() } } /// If provided, will apply margin to pagination arrows. Can be set to either positive or negative values. /// The default value will be 3X in tablet and 2X in mobile. These values are the default in order to avoid overlapping content within the carousel. open var paginationInset: CGFloat = UIDevice.isIPad ? VDSLayout.space3X : VDSLayout.space2X { didSet { 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 = .standard { didSet { setNeedsUpdate() } } /// The initial visible slide's index in the carousel. open var groupIndex: Int = 0 { didSet { setNeedsUpdate() } } /// If provided, will set the alignment for slot content when the slots has different heights. open var slotAlignment: CarouselSlotAlignmentModel? = .init(vertical: .top, horizontal: .left) { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- internal var containerSize: CGSize { CGSize(width: frame.size.width, 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 moving the carousel. Passes parameters selectedGroupIndex (position). open var onChangePublisher = PassthroughSubject() private var onChangeCancellable: AnyCancellable? private var containerStackHeightConstraint: NSLayoutConstraint? private var containerViewHeightConstraint: NSLayoutConstraint? private var prevButtonLeadingConstraint: NSLayoutConstraint? private var nextButtonTrailingConstraint: 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 slotDefaultHeight = 50.0 var peekMinimum = 24.0 var minimumSlotWidth = 0.0 //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- /// Executed on initialization for this View. open override func initialSetup() { super.initialSetup() } /// Called once when a view is initialized and is used to Setup additional UI or other constants and configurations. open override func setup() { super.setup() isAccessibilityElement = false // Add containerView addSubview(containerView) containerView .pinTop() .pinBottom() .pinLeading() .pinTrailing() .heightGreaterThanEqualTo(containerSize.height) containerView.centerYAnchor.constraint(equalTo: centerYAnchor).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() updatePaginationInset() } /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() carouselScrollBar.numberOfSlides = views.count carouselScrollBar.layout = layout if (carouselScrollBar.position == 0 || carouselScrollBar.position > carouselScrollBar.numberOfSlides) { carouselScrollBar.position = 1 } 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() addCarouselSlots() } /// Resets to default settings. open override func reset() { super.reset() shouldUpdateView = false layout = UIDevice.isIPad ? .threeUP : .oneUP pagination = .init(kind: .lowContrast, floating: true) paginationDisplay = .none paginationInset = UIDevice.isIPad ? VDSLayout.space3X : VDSLayout.space2X gutter = UIDevice.isIPad ? .gutter6X : .gutter3X peek = .standard } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- private func addlisteners() { nextButton.onClick = { _ in self.nextButtonClick() } previousButton.onClick = { _ in self.previousButtonClick() } /// Will be called when the scrubber position changes. carouselScrollBar.onScrubberDrag = { [weak self] scrubberId in guard let self else { return } updateScrollPosition(position: scrubberId, callbackText:"onThumbPositionChange") } /// Will be called when the scrollbar thumb move forward. carouselScrollBar.onMoveForward = { [weak self] scrubberId in guard let self else { return } updateScrollPosition(position: scrubberId, callbackText:"onMoveForward") } /// Will be called when the scrollbar thumb move backward. carouselScrollBar.onMoveBackward = { [weak self] scrubberId in guard let self else { return } updateScrollPosition(position: scrubberId, callbackText:"onMoveBackward") } /// Will be called when the scrollbar thumb touch start. carouselScrollBar.onThumbTouchStart = { [weak self] scrubberId in guard let self else { return } updateScrollPosition(position: scrubberId, callbackText:"onThumbTouchStart") } /// Will be called when the scrollbar thumb touch end. carouselScrollBar.onThumbTouchEnd = { [weak self] scrubberId in guard let self else { return } updateScrollPosition(position: scrubberId, callbackText:"onThumbTouchEnd") } } // Update pagination buttons with selected surface, kind, floating values private 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 } // Show/Hide pagination buttons of Carousel based on First or Middle or Last private 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) } } private func estimateHeightFor(component: UIView, with itemWidth: CGFloat) -> CGFloat { let maxSize = CGSize(width: itemWidth, height: CGFloat.greatestFiniteMagnitude) let estItemSize = component.systemLayoutSizeFitting(maxSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) return estItemSize.height } private func fetchCarouselHeight() -> CGFloat { var height = slotDefaultHeight if views.count > 0 { for index in 0...views.count - 1 { let estHeight = estimateHeightFor(component: views[index], with: minimumSlotWidth) height = estHeight > height ? estHeight : height } } return height } // Add carousel slots and load data if any private func addCarouselSlots() { getSlotWidth() if containerView.frame.size.width > 0 { containerViewHeightConstraint?.isActive = false containerStackHeightConstraint?.isActive = false let slotHeight = fetchCarouselHeight() // Perform a loop to iterate each subView scrollView.subviews.forEach { subView in // Removing subView from its parent view subView.removeFromSuperview() } // Add carousel items if views.count > 0 { var xPos = 0.0 for index in 0...views.count - 1 { // Add Carousel Slot let carouselSlot = View().with { $0.clipsToBounds = true } scrollView.addSubview(carouselSlot) scrollView.delegate = self carouselSlot .pinTop() .pinBottom() .pinLeading(xPos) .width(minimumSlotWidth) .height(slotHeight) xPos = xPos + minimumSlotWidth + gutter.value let component = views[index] carouselSlot.addSubview(component) setSlotAlignment(contentView: component) } 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 } } // Set slot alignment if provided. Used only when slot content have different heights or widths. private func setSlotAlignment(contentView: UIView) { switch slotAlignment?.vertical { case .top: contentView .pinTop() .pinBottomLessThanOrEqualTo() case .middle: contentView .pinTopGreaterThanOrEqualTo() .pinBottomLessThanOrEqualTo() .pinCenterY() case .bottom: contentView .pinTopGreaterThanOrEqualTo() .pinBottom() default: break } switch slotAlignment?.horizontal { case .left: contentView .pinLeading() .pinTrailingLessThanOrEqualTo() case .center: contentView .pinLeadingGreaterThanOrEqualTo() .pinTrailingLessThanOrEqualTo() .pinCenterX() case .right: contentView .pinLeadingGreaterThanOrEqualTo() .pinTrailing() default: break } } // Get the slot width relative to the peak private func getSlotWidth() { let actualWidth = containerView.frame.size.width let isScrollbarSuppressed = views.count > 0 && layout.value == views.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 - gutter.value case .none: break } } minimumSlotWidth = ceil(minimumSlotWidth / CGFloat(layout.value)) } private func nextButtonClick() { carouselScrollBar.position = carouselScrollBar.position+1 showPaginationControls() updateScrollPosition(position: carouselScrollBar.position, callbackText:"pageControlClicks") } private func previousButtonClick() { carouselScrollBar.position = carouselScrollBar.position-1 showPaginationControls() updateScrollPosition(position: carouselScrollBar.position, callbackText:"pageControlClicks") } private 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 } private func updateScrollbarPosition(targetContentOffsetXPos:CGFloat) { let scrollContentSizeWidth = scrollView.contentSize.width let totalPositions = totalPositions() let layoutSpace = Int (floor( Double(scrollContentSizeWidth / Double(totalPositions)))) let remindSpace = Int(targetContentOffsetXPos) % layoutSpace var contentPos = (Int(targetContentOffsetXPos) / layoutSpace) + 1 contentPos = remindSpace > layoutSpace/2 ? contentPos+1 : contentPos carouselScrollBar.position = contentPos updateScrollPosition(position: contentPos, callbackText: "ScrollViewMoved") } // Update scrollview offset relative to scrollbar thumb position private func updateScrollPosition(position: Int, callbackText: String) { if carouselScrollBar.numberOfSlides > 0 { let scrollContentSizeWidth = scrollView.contentSize.width let totalPositions = totalPositions() var xPos = 0.0 if position == 1 { xPos = 0.0 } else if position == totalPositions { xPos = scrollContentSizeWidth - containerView.frame.size.width } else { let isScrollbarSuppressed = views.count > 0 && layout.value == views.count let isPeekMinimumOnTablet = UIDevice.isIPad && peek == .minimum if !isScrollbarSuppressed { let slotWidthWithGutter = minimumSlotWidth + gutter.value let xPosition = CGFloat( Float(position-1) * Float(layout.value) * Float(slotWidthWithGutter)) let peekWidth = (containerView.frame.size.width - gutter.value - (Double(layout.value) * (minimumSlotWidth + gutter.value)))/2 xPos = (peek == .none || isPeekMinimumOnTablet) ? xPosition : xPosition - gutter.value - peekWidth } } carouselScrollBar.scrubberId = position+1 let yPos = scrollView.contentOffset.y scrollView.setContentOffset(CGPoint(x: xPos, y: yPos), animated: true) showPaginationControls() groupIndex = position-1 onChangePublisher.send(groupIndex) } } // Get the overall positions of the carousel scrollbar relative to the slides and selected layout private func totalPositions() -> Int { return Int (ceil (Double(carouselScrollBar.numberOfSlides) / Double(layout.value))) } } extension Carousel: UIScrollViewDelegate { //-------------------------------------------------- // MARK: - UIScrollView Delegate //-------------------------------------------------- public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { updateScrollbarPosition(targetContentOffsetXPos: targetContentOffset.pointee.x) } }