599 lines
26 KiB
Swift
599 lines
26 KiB
Swift
//
|
||
// 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
|
||
//--------------------------------------------------
|
||
/// 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)
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|
||
}
|
||
|
||
//--------------------------------------------------
|
||
// 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 24px (6X) in tablet and 12px (3X) in mobile.
|
||
open var gutter: Gutter = UIDevice.isIPad ? .twentyFourPX : .twelvePX { 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 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 = 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 selectedIndex: Int? { didSet { setNeedsUpdate() } }
|
||
|
||
/// If provided, will set the alignment for slot content when the slots has different heights.
|
||
open var slotAlignment: CarouselSlotAlignmentModel? = nil { 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<Int, Never>()
|
||
private var onChangeCancellable: AnyCancellable?
|
||
|
||
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 slotDefaultHeight = 50.0
|
||
var peekMinimum = 24.0
|
||
var minimumSlotWidth = 0.0
|
||
var carouselScrollbarMinWidth = 96.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 ? .twentyFourPX : .twelvePX
|
||
peek = .standard
|
||
}
|
||
|
||
//--------------------------------------------------
|
||
// MARK: - Private Methods
|
||
//--------------------------------------------------
|
||
private func addlisteners() {
|
||
nextButton.onClick = { _ in self.nextButtonClick() }
|
||
previousButton.onClick = { _ in self.previousButtonClick() }
|
||
|
||
/// 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(item: CarouselSlotItemModel, with width: CGFloat) -> CGFloat {
|
||
let itemWidth = width
|
||
let maxSize = CGSize(width: itemWidth, height: CGFloat.greatestFiniteMagnitude)
|
||
let estItemSize = item.component?.systemLayoutSizeFitting(maxSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) ?? CGSize(width: itemWidth, height: item.defaultHeight)
|
||
return estItemSize.height
|
||
}
|
||
|
||
private func fetchCarouselHeight() -> CGFloat {
|
||
var height = slotDefaultHeight
|
||
if views.count > 0 {
|
||
for x in 0...views.count - 1 {
|
||
let item : CarouselSlotItemModel = .init(component: views[x])
|
||
let estHeight = estimateHeightFor(item: item, 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 x in 0...views.count - 1 {
|
||
|
||
// Add Carousel Slot
|
||
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
|
||
|
||
carouselSlot
|
||
.pinTop()
|
||
.pinBottom()
|
||
.pinLeading(xPos)
|
||
.width(minimumSlotWidth)
|
||
.height(slotHeight)
|
||
carouselSlot.layer.cornerRadius = 12.0
|
||
xPos = xPos + minimumSlotWidth + gutter.value
|
||
|
||
// Add received component
|
||
let item : CarouselSlotItemModel = .init(component: views[x])
|
||
let contentViewHeight = estimateHeightFor(item: item, with: minimumSlotWidth)
|
||
|
||
// Add subview for content to Carousel Slot
|
||
let contentView = View().with {
|
||
$0.clipsToBounds = true
|
||
}
|
||
carouselSlot.addSubview(contentView)
|
||
|
||
if let component = item.component {
|
||
if slotAlignment != nil {
|
||
// If slotAlignment exist, should use expected height
|
||
contentView.widthAnchor.constraint(equalToConstant: minimumSlotWidth).activate()
|
||
contentView.heightAnchor.constraint(equalToConstant: contentViewHeight).activate()
|
||
setSlotAlignment(contentView: contentView, parentView: carouselSlot)
|
||
} else {
|
||
// If no slotAlignment, should use full slot
|
||
contentView.pinToSuperView()
|
||
}
|
||
carouselSlot.backgroundColor = .clear
|
||
carouselSlot.layer.cornerRadius = 0
|
||
contentView.addSubview(component)
|
||
component.pinToSuperView()
|
||
if var surfacedView = component as? Surfaceable {
|
||
contentView.surface = surface
|
||
surfacedView.surface = surface
|
||
}
|
||
}
|
||
}
|
||
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: View, parentView: View) {
|
||
parentView.backgroundColor = .clear
|
||
switch slotAlignment?.vertical {
|
||
case .top:
|
||
contentView.topAnchor.constraint(equalTo: parentView.topAnchor).activate()
|
||
break
|
||
case .middle:
|
||
contentView.centerYAnchor.constraint(equalTo: parentView.centerYAnchor).activate()
|
||
break
|
||
case .bottom:
|
||
contentView.bottomAnchor.constraint(equalTo: parentView.bottomAnchor).activate()
|
||
break
|
||
default: break
|
||
}
|
||
|
||
switch slotAlignment?.horizontal {
|
||
case .left:
|
||
contentView.leadingAnchor.constraint(equalTo: parentView.leadingAnchor).activate()
|
||
break
|
||
case .center:
|
||
contentView.centerXAnchor.constraint(equalTo: parentView.centerXAnchor).activate()
|
||
break
|
||
case .right:
|
||
parentView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).activate()
|
||
break
|
||
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 = 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 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
|
||
case .none:
|
||
xPos = xPosition
|
||
}
|
||
}
|
||
}
|
||
carouselScrollBar.scrubberId = position+1
|
||
let yPos = scrollView.contentOffset.y
|
||
scrollView.setContentOffset(CGPoint(x: xPos, y: yPos), animated: true)
|
||
showPaginationControls()
|
||
onChangePublisher.send(position-1)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||
var visibleRect = CGRect()
|
||
visibleRect.origin = scrollView.contentOffset
|
||
updateScrollbarPosition(targetContentOffsetXPos: visibleRect.origin.x)
|
||
}
|
||
}
|