vds_ios/VDS/Components/CarouselScrollbar/CarouselScrollbar.swift

350 lines
14 KiB
Swift

//
// CarouselScrollbar.swift
// VDS
//
// Created by Kanamarlapudi, Vasavi on 12/03/24.
//
import Foundation
import UIKit
import VDSColorTokens
import VDSFormControlsTokens
import Combine
/// A carousel scrollbar is a control that allows to navigate between items in a carousel.
/// It's also a status indicator that conveys the relative amount of content in a carousel and a location within it.
@objc(VDSCarouselScrollbar)
open class CarouselScrollbar: 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: - Public Properties
//--------------------------------------------------
/// Used to set total number of slides within carousel
open var numberOfSlides: Int? {
get { return _numberOfSlides }
set {
if let newValue {
_numberOfSlides = newValue
} else {
_numberOfSlides = 1
}
setThumbWidth()
scrollThumbToPosition(position)
setNeedsUpdate()
}
}
/// The number of slides that can appear at once in a set in a carousel container.
open var selectedLayout: Layout? {
get { return _selectedLayout }
set {
if let newValue {
_selectedLayout = newValue
} else {
_selectedLayout = .oneUP
}
setThumbWidth()
scrollThumbToPosition(position)
setNeedsUpdate()
}
}
/// Enum used to describe the number of slides that can appear at once in a set in a carousel container.
public enum Layout: String, CaseIterable {
case oneUP = "1UP"
case twoUP = "2UP"
case threeUP = "3UP"
case fourUP = "4UP"
case fiveUP = "5UP"
case sixUP = "6UP"
case eightUP = "8UP"
var value: Int {
switch self {
case .oneUP:
1
case .twoUP:
2
case .threeUP:
3
case .fourUP:
4
case .fiveUP:
5
case .sixUP:
6
case .eightUP:
8
}
}
}
/// Used to set the position of the thumb(scrubber). This is used when the carousel container changes position, it will align the position of thumb(scrubber).
open var position: Int? {
get { return _position }
set {
if let newValue {
checkPositions()
_position = (newValue > totalPositions ?? 1) ? totalPositions : newValue
} else {
_position = 1
}
scrollThumbToPosition(position)
setNeedsUpdate()
}
}
/// Allows a unique id to be passed into the thumb and track of the thumb(scrubber).
open var scrubberId: Int? { didSet { setNeedsUpdate() } }
/// A callback when the scrubber position changes. Passes parameters (position).
open var onScrubberDidChange: ((Int) -> Void)?
//--------------------------------------------------
// MARK: - Private Properties
//--------------------------------------------------
// Sizes are from InVision design specs.
internal var containerSize: CGSize { CGSize(width: 45, height: 44) }
internal var _selectedLayout: Layout = .oneUP
internal var _numberOfSlides: Int? = 1
internal var heightConstraint: NSLayoutConstraint?
internal var totalPositions: Int? = 1
internal var _position: Int? = 1
internal var trayOriginalCenter: CGPoint!
private let trackViewWidth = 96
private let trackViewHeight: CGFloat = 4
private let minThumbWidth: Float = 16.0
private var thumbWidth: Float = 16.0
private var actualThumbWidth: Float = 0.0
private let cornerRadius: CGFloat = 4.0
private let activeOpacity: Float = 0.15
private let defaultOpacity: Float = 1
/// Track view with fixed width
internal var trackView: UIView = {
return UIView().with {
$0.translatesAutoresizingMaskIntoConstraints = false
}
}()
/// Left Active Track overlay with variable width
internal var leftActiveOverlay: UIView = {
return UIView().with {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.tag = 2
}
}()
/// Right Active Track overlay with variable width
internal var rightActiveOverlay: UIView = {
return UIView().with {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.tag = 3
}
}()
/// Thumb view with variable width
internal var thumbView: UIView = {
return UIView().with {
$0.translatesAutoresizingMaskIntoConstraints = false
}
}()
//--------------------------------------------------
// MARK: - Configuration
//--------------------------------------------------
private var thumbColorConfiguration = SurfaceColorConfiguration(VDSColor.interactiveScrollthumbOnlight , VDSColor.interactiveScrollthumbOndark)
private var trackColorConfiguration = SurfaceColorConfiguration(VDSColor.interactiveScrolltrackOnlight , VDSColor.interactiveScrolltrackOndark)
private var activeOverlayColorConfiguration = SurfaceColorConfiguration(VDSColor.paletteBlack, VDSColor.paletteWhite)
//--------------------------------------------------
// MARK: - Lifecycle
//--------------------------------------------------
open override func initialSetup() {
super.initialSetup()
}
open override func setup() {
super.setup()
accessibilityLabel = "Carousel Scrollbar"
//create the wrapping view
heightConstraint = self.heightAnchor.constraint(equalToConstant: containerSize.height)
heightConstraint?.priority = .defaultHigh
heightConstraint?.isActive = true
//Trackview
trackView.frame = CGRectMake(20, 20, CGFloat(trackViewWidth), trackViewHeight)
trackView.layer.cornerRadius = cornerRadius
addSubview(trackView)
///Left active overlay
leftActiveOverlay.frame = CGRectMake(trackView.frame.origin.x, 20, CGFloat(trackViewWidth), trackViewHeight)
leftActiveOverlay.isUserInteractionEnabled = true
leftActiveOverlay.layer.cornerRadius = cornerRadius
let leftPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.onThumbTouchStart(_:)))
leftPressRecognizer.minimumPressDuration = 0
leftActiveOverlay.addGestureRecognizer(leftPressRecognizer)
addSubview(leftActiveOverlay)
///Right active overlay
rightActiveOverlay.frame = CGRectMake(thumbView.frame.origin.x + thumbView.frame.size.width, 20, CGFloat(trackViewWidth), trackViewHeight)
rightActiveOverlay.isUserInteractionEnabled = true
rightActiveOverlay.layer.cornerRadius = cornerRadius
let rightPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.onThumbTouchEnd(_:)))
rightPressRecognizer.minimumPressDuration = 0
rightActiveOverlay.addGestureRecognizer(rightPressRecognizer)
addSubview(rightActiveOverlay)
//Thumbview
thumbView.frame = CGRectMake(20, 20, CGFloat(thumbWidth), trackViewHeight)
thumbView.layer.cornerRadius = cornerRadius
thumbView.addGestureRecognizer(UIPanGestureRecognizer(target: self, action:(#selector(self.onScrubberDrag(_:)))))
addSubview(thumbView)
updateActiveOverlays()
}
open override func updateView() {
super.updateView()
trackView.backgroundColor = trackColorConfiguration.getColor(surface)
thumbView.backgroundColor = thumbColorConfiguration.getColor(surface)
}
open override func updateAccessibility() {
super.updateAccessibility()
}
open override func reset() {
for subview in subviews {
for recognizer in subview.gestureRecognizers ?? [] {
subview.removeGestureRecognizer(recognizer)
}
}
super.reset()
}
//--------------------------------------------------
// MARK: - Private Methods
//--------------------------------------------------
func onMoveBackward() {
position = (position ?? 1) - 1
scrollThumbToPosition(position)
}
func onMoveForward() {
position = (position ?? 1) + 1
scrollThumbToPosition(position)
}
// Drag scrollbar thumb to move it to the left or right.
// Upon releases of drag, the scrollbar thumb snaps to the closest full position and the scrollbar returns to original size without delay.
@objc func onScrubberDrag(_ sender: UIPanGestureRecognizer) {
let translation = sender.translation(in: thumbView)
if sender.state == UIGestureRecognizer.State.began {
trayOriginalCenter = thumbView.center
} else if sender.state == UIGestureRecognizer.State.changed {
let draggedPositions = Int (ceil (Double(translation.x) / Double(actualThumbWidth)))
setThumb(at: (position ?? 1) + draggedPositions)
}
else if sender.state == UIGestureRecognizer.State.cancelled || sender.state == UIGestureRecognizer.State.ended {
let draggedPositions = Int (ceil (Double(translation.x) / Double(actualThumbWidth)))
position = (((position ?? 1) + draggedPositions) < 1) ? 1 : ((position ?? 1) + draggedPositions)
}
}
// Move the scrollbar thumb to the left while tapping on the left side of the scrubber.
@objc func onThumbTouchStart(_ gesture: UILongPressGestureRecognizer) {
if gesture.state == .began {
leftActiveOverlay.backgroundColor = activeOverlayColorConfiguration.getColor(self)
leftActiveOverlay.layer.opacity = activeOpacity
} else if gesture.state == .cancelled {
leftActiveOverlay.backgroundColor = .clear
leftActiveOverlay.layer.opacity = defaultOpacity
} else if gesture.state == .ended {
leftActiveOverlay.backgroundColor = .clear
leftActiveOverlay.layer.opacity = defaultOpacity
self.onMoveBackward()
}
}
// Move the scrollbar thumb to the right while tapping on the right side of the scrubber.
@objc func onThumbTouchEnd(_ gesture: UILongPressGestureRecognizer) {
if gesture.state == .began {
rightActiveOverlay.backgroundColor = activeOverlayColorConfiguration.getColor(self)
rightActiveOverlay.layer.opacity = activeOpacity
} else if gesture.state == .cancelled {
rightActiveOverlay.backgroundColor = .clear
rightActiveOverlay.layer.opacity = defaultOpacity
} else if gesture.state == .ended {
rightActiveOverlay.backgroundColor = .clear
rightActiveOverlay.layer.opacity = defaultOpacity
self.onMoveForward()
}
}
// Minimum thumb width applied
// Incomplete set moves a shorter distance than the standard increment value.
private func setThumbWidth() {
let width = (Float(trackViewWidth) / Float(numberOfSlides ?? 1)) * Float(_selectedLayout.value)
actualThumbWidth = (width > Float(trackViewWidth)) ? Float(trackViewWidth) : width
thumbWidth = (width <= Float(trackViewWidth) && width > minThumbWidth) ? width : ((width > Float(trackViewWidth)) ? Float(trackViewWidth) : minThumbWidth)
thumbView.frame.size.width = CGFloat(thumbWidth)
thumbView.frame.origin.x = trackView.frame.origin.x
checkPositions()
updateActiveOverlays()
}
// Update active overlay frames according to thumb position.
private func updateActiveOverlays() {
// adjusting thumb position if it goes beyond trackView.
let thumbPosition = thumbView.frame.origin.x + thumbView.frame.size.width
let trackPosition = trackView.frame.origin.x + trackView.frame.size.width
if thumbPosition > trackPosition {
thumbView.frame.origin.x = trackPosition - thumbView.frame.size.width
} else if thumbView.frame.origin.x < trackView.frame.origin.x {
thumbView.frame.origin.x = trackView.frame.origin.x
}
//left active overlay position update
leftActiveOverlay.frame.size.width = thumbView.frame.origin.x - trackView.frame.origin.x + cornerRadius
//left active overlay position update
rightActiveOverlay.frame.origin.x = thumbView.frame.origin.x + thumbView.frame.size.width - cornerRadius
rightActiveOverlay.frame.size.width = (trackView.frame.origin.x + trackView.frame.size.width) - (thumbView.frame.origin.x + thumbView.frame.size.width) + cornerRadius
}
private func checkPositions() {
totalPositions = Int (ceil (Double(numberOfSlides ?? 1) / Double(_selectedLayout.value)))
}
private func scrollThumbToPosition(_ position: Int?) {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in
self?.setThumb(at: position)
self?.onScrubberDidChange?(position ?? 1)
}
}
private func setThumb(at position: Int?) {
thumbView.frame.origin.x = CGFloat(Float((position ?? 1) - 1) * actualThumbWidth) + trackView.frame.origin.x
updateActiveOverlays()
}
}