vds_ios/VDS/Components/Carousel/Carousel.swift
Matt Bruce 35036ca804 more updates on carousel
Signed-off-by: Matt Bruce <matt.bruce@verizon.com>
2024-08-27 13:31:48 -05:00

541 lines
23 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 carousels 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, Valuing {
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 lazy var collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
let collectionView = UICollectionView(frame: frame, collectionViewLayout: layout)
collectionView.isScrollEnabled = true
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.delegate = self
collectionView.dataSource = self
collectionView.showsHorizontalScrollIndicator = false
collectionView.showsVerticalScrollIndicator = false
collectionView.backgroundColor = .clear
collectionView.register(CarouselSlotCell.self,
forCellWithReuseIdentifier: CarouselSlotCell.identifier)
return collectionView
}()
/// Previous button to show previous slide.
private var previousButton = ButtonIcon().with {
$0.kind = .lowContrast
$0.iconName = .paginationLeftCaret
$0.iconOffset = .init(x: -2, y: 0)
$0.customContainerSize = UIDevice.isIPad ? 40 : 28
$0.customIconSize = UIDevice.isIPad ? 16 : 12
}
/// Next button to show next slide.
private var nextButton = ButtonIcon().with {
$0.kind = .lowContrast
$0.iconName = .paginationRightCaret
$0.iconOffset = .init(x: 2, y: 0)
$0.customContainerSize = UIDevice.isIPad ? 40 : 28
$0.customIconSize = UIDevice.isIPad ? 16 : 12
}
/// A publisher for when moving the carousel. Passes parameters selectedGroupIndex (position).
open var onChangePublisher = PassthroughSubject<Int, Never>()
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
//--------------------------------------------------
/// 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(collectionView)
collectionView.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()
}
open override func setDefaults() {
super.setDefaults()
gutter = UIDevice.isIPad ? .gutter6X : .gutter3X
layout = UIDevice.isIPad ? .threeUP : .oneUP
onChange = nil
pagination = .init(kind: .lowContrast, floating: true)
paginationDisplay = .none
paginationInset = UIDevice.isIPad ? VDSLayout.space3X : VDSLayout.space2X
peek = .standard
groupIndex = 0
}
/// Used to make changes to the View based off a change events or from local properties.
open override func updateView() {
super.updateView()
updateScrollbar()
updateCarousel()
collectionView.collectionViewLayout.invalidateLayout()
collectionView.reloadData()
}
//--------------------------------------------------
// MARK: - Private Methods
//--------------------------------------------------
private func updateScrollbar() {
carouselScrollBar.numberOfSlides = views.count
carouselScrollBar.layout = layout
if (carouselScrollBar.position == 0 || carouselScrollBar.position > carouselScrollBar.numberOfSlides) {
carouselScrollBar.position = 1
}
carouselScrollBar.isHidden = (totalPositions() <= 1) ? true : false
}
private func updateCarousel() {
// 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()
updateContainerHeight()
}
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
}
// update carousel size and load data if any
private func updateContainerHeight() {
getSlotWidth()
if containerView.frame.size.width > 0 {
containerViewHeightConstraint?.isActive = false
containerStackHeightConstraint?.isActive = false
let slotHeight = fetchCarouselHeight()
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
}
}
// 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 = collectionView.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 collectionview offset relative to scrollbar thumb position
private func updateScrollPosition(position: Int, callbackText: String) {
if carouselScrollBar.numberOfSlides > 0 {
let scrollContentSizeWidth = collectionView.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 = collectionView.contentOffset.y
collectionView.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<CGPoint>) {
updateScrollbarPosition(targetContentOffsetXPos: targetContentOffset.pointee.x)
}
}
extension Carousel: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
//--------------------------------------------------
// MARK: - UICollectionView Delegate & Datasource
//--------------------------------------------------
public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
views.count
}
public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CarouselSlotCell.identifier, for: indexPath) as? CarouselSlotCell else { return UICollectionViewCell() }
let component = views[indexPath.row]
cell.update(with: component, slotAlignment: slotAlignment, surface: surface)
cell.layoutIfNeeded()
//component.setNeedsLayout()
if hasDebugBorder {
cell.addDebugBorder()
} else {
cell.removeDebugBorder()
}
return cell
}
public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
return gutter.value
}
public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: minimumSlotWidth, height: fetchCarouselHeight())
}
}