vds_ios/VDS/Components/Carousel/Carousel.swift

628 lines
25 KiB
Swift
Raw 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.
@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 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
}
/// 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<Int, Never>()
private var onChangeCancellable: AnyCancellable?
/// A publisher for when the carousel moves. Passes parameters (data).
open var onScrollPublisher = PassthroughSubject<Array<Any>, 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()
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() {
getSlotWidth()
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)
scrollView.delegate = self
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()
var xPos = 0.0
if position == 1 {
xPos = 0.0
} else if position == totalPositions {
xPos = scrollContentSizeWidth - containerView.frame.size.width
} else {
let isScrollbarSuppressed = data.count > 0 && layout.value == data.count
let isPeekMinimumOnTablet = UIDevice.isIPad && peek == .minimum
if !isScrollbarSuppressed {
let subpart = minimumSlotWidth + gutter.value
let xPosition = CGFloat( Float(position-1) * Float(layout.value) * Float(subpart))
switch peek {
case .standard:
if UIDevice.isIPad {
xPos = xPosition - (minimumSlotWidth/(CGFloat(layout.value) + 3))/2
} else if layout == .oneUP {
xPos = xPosition - gutter.value - (minimumSlotWidth/4)/2
}
case .minimum:
xPos = isPeekMinimumOnTablet ? xPosition : xPosition - peekMinimum/2
case .none:
xPos = xPosition
}
}
}
carouselScrollBar.scrubberId = position+1
let yPos = scrollView.contentOffset.y
scrollView.setContentOffset(CGPoint(x: xPos, y: yPos), animated: true)
showPaginationControls()
}
}
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")
}
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)
}
}
}
extension Carousel: UIScrollViewDelegate {
//--------------------------------------------------
// MARK: - UIScrollView Delegate
//--------------------------------------------------
public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
updateScrollbarPosition(targetContentOffsetXPos: targetContentOffset.pointee.x)
}
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
var visibleRect = CGRect()
visibleRect.origin = scrollView.contentOffset
updateScrollbarPosition(targetContentOffsetXPos: visibleRect.origin.x)
}
}