// // CarouselScrollbar.swift // VDS // // Created by Kanamarlapudi, Vasavi on 12/03/24. // import Foundation import UIKit import VDSCoreTokens 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 { _numberOfSlides = newValue setThumbWidth() scrollThumbToPosition(position) setNeedsUpdate() } } /// The number of slides that can appear at once in a set in a carousel container. open var layout: Layout? { get { return _layout } set { if let newValue { _layout = newValue } else { _layout = .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 { checkPositions() _position = (newValue > totalPositions) ? totalPositions : newValue 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 onScrubberDrag: ((Int) -> Void)? { didSet { onScrubberDragCancellable?.cancel() if let onScrubberDrag { onScrubberDragCancellable = onScrubberDragPublisher .sink { c in onScrubberDrag(c) } } else { onScrubberDragCancellable = nil } } } /// A publisher for when the scrubber position changes. Passes parameters (position). open var onScrubberDragPublisher = PassthroughSubject() private var onScrubberDragCancellable: AnyCancellable? /// A callback when the thumb move forward. Passes parameters (position). open var onMoveForward: ((Int) -> Void)? { didSet { onMoveForwardCancellable?.cancel() if let onMoveForward { onMoveForwardCancellable = onMoveForwardPublisher .sink { c in onMoveForward(c) } } else { onMoveForwardCancellable = nil } } } /// A publisher for when the thumb move forward. Passes parameters (position). open var onMoveForwardPublisher = PassthroughSubject() private var onMoveForwardCancellable: AnyCancellable? /// A callback when the thumb move backward. Passes parameters (position). open var onMoveBackward: ((Int) -> Void)? { didSet { onMoveBackwardCancellable?.cancel() if let onMoveBackward { onMoveBackwardCancellable = onMoveBackwardPublisher .sink { c in onMoveBackward(c) } } else { onMoveBackwardCancellable = nil } } } /// A publisher for when the thumb move backward. Passes parameters (position). open var onMoveBackwardPublisher = PassthroughSubject() private var onMoveBackwardCancellable: AnyCancellable? /// A callback when the thumb touch start. Passes parameters (position). open var onThumbTouchStart: ((Int) -> Void)? { didSet { onThumbTouchStartCancellable?.cancel() if let onThumbTouchStart { onThumbTouchStartCancellable = onThumbTouchStartPublisher .sink { c in onThumbTouchStart(c) } } else { onThumbTouchStartCancellable = nil } } } /// A publisher for when the thumb touch start. Passes parameters (position). open var onThumbTouchStartPublisher = PassthroughSubject() private var onThumbTouchStartCancellable: AnyCancellable? /// A callback when the thumb touch end. Passes parameters (position). open var onThumbTouchEnd: ((Int) -> Void)? { didSet { onThumbTouchEndCancellable?.cancel() if let onThumbTouchEnd { onThumbTouchEndCancellable = onThumbTouchEndPublisher .sink { c in onThumbTouchEnd(c) } } else { onThumbTouchEndCancellable = nil } } } /// A publisher for when the thumb touch end. Passes parameters (position). open var onThumbTouchEndPublisher = PassthroughSubject() private var onThumbTouchEndCancellable: AnyCancellable? //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- // Sizes are from InVision design specs. internal var containerSize: CGSize { CGSize(width: 45, height: 44) } internal var _layout: Layout = .oneUP internal var _numberOfSlides: Int = 1 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 computedWidth: Float = 0.0 private let cornerRadius: CGFloat = 2.0 private let activeOpacity: Float = 0.15 private let defaultOpacity: Float = 1 internal var containerView = View().with { $0.clipsToBounds = true } internal var trackView = View() internal var leftActiveOverlay = View() internal var rightActiveOverlay = View() internal var thumbView = View() internal var rightActiveOverlayLayer: CALayer = CALayer() internal var leftActiveOverlayLayer: CALayer = CALayer() internal var thumbViewLayer: CALayer = CALayer() //-------------------------------------------------- // 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 setup() { super.setup() isAccessibilityElement = false addSubview(containerView) containerView .pinTop() .pinBottom() .pinLeadingGreaterThanOrEqualTo() .pinTrailingLessThanOrEqualTo() .height(containerSize.height) .width(CGFloat(trackViewWidth)) containerView.centerXAnchor.constraint(equalTo: centerXAnchor).activate() //Trackview trackView.frame = CGRectMake(0, 20, CGFloat(trackViewWidth), trackViewHeight) trackView.layer.cornerRadius = cornerRadius containerView.addSubview(trackView) ///Left active overlay leftActiveOverlay.frame = CGRectMake(trackView.frame.origin.x, 0, CGFloat(trackViewWidth), containerSize.height) leftActiveOverlay.isUserInteractionEnabled = true leftActiveOverlay.backgroundColor = .clear let leftPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(onLeftViewLongPressRecognizer(_:))) leftPressRecognizer.minimumPressDuration = 0 leftActiveOverlay.addGestureRecognizer(leftPressRecognizer) containerView.addSubview(leftActiveOverlay) leftActiveOverlay.layer.addSublayer(leftActiveOverlayLayer) leftActiveOverlayLayer.cornerRadius = cornerRadius leftActiveOverlayLayer.frame = .init(origin: .zero, size: .init(width: leftActiveOverlayLayer.frame.size.width, height: trackViewHeight)) ///Right active overlay rightActiveOverlay.frame = CGRectMake(thumbView.frame.origin.x + thumbView.frame.size.width, 0, CGFloat(trackViewWidth), containerSize.height) rightActiveOverlay.isUserInteractionEnabled = true rightActiveOverlay.backgroundColor = .clear let rightPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(onRightViewLongPressRecognizer(_:))) rightPressRecognizer.minimumPressDuration = 0 rightActiveOverlay.addGestureRecognizer(rightPressRecognizer) containerView.addSubview(rightActiveOverlay) rightActiveOverlay.layer.addSublayer(rightActiveOverlayLayer) rightActiveOverlayLayer.cornerRadius = cornerRadius rightActiveOverlayLayer.frame = .init(origin: .zero, size: .init(width: rightActiveOverlay.frame.size.width, height: trackViewHeight)) //Thumbview thumbView.frame = CGRectMake(0, 0, CGFloat(thumbWidth), containerSize.height) thumbView.backgroundColor = .clear thumbView.addGestureRecognizer(UIPanGestureRecognizer(target: self, action:(#selector(onScrubberChange(_:))))) containerView.addSubview(thumbView) updateActiveOverlays() thumbViewLayer.cornerRadius = cornerRadius thumbViewLayer.backgroundColor = thumbColorConfiguration.getColor(surface).cgColor thumbViewLayer.frame = .init(origin: .init(x: 0, y: 20), size: .init(width: CGFloat(thumbWidth), height: trackViewHeight)) thumbView.layer.addSublayer(thumbViewLayer) } open override func setDefaults() { super.setDefaults() onMoveForward = nil onMoveBackward = nil onScrubberDrag = nil onThumbTouchEnd = nil onThumbTouchStart = nil layout = .oneUP numberOfSlides = 1 totalPositions = 1 position = 1 } open override func updateView() { super.updateView() trackView.backgroundColor = trackColorConfiguration.getColor(surface) thumbViewLayer.backgroundColor = thumbColorConfiguration.getColor(surface).cgColor } open override func reset() { for subview in subviews { for recognizer in subview.gestureRecognizers ?? [] { subview.removeGestureRecognizer(recognizer) } } super.reset() } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- func movePositionBackward() { position = position - 1 scrollThumbToPosition(position) onMoveBackwardPublisher.send(position) } func movePositionForward() { position = position + 1 scrollThumbToPosition(position) onMoveForwardPublisher.send(position) } // Compute track width and should maintain minimum thumb width if needed private func setThumbWidth() { let width = (Float(trackViewWidth) / Float(numberOfSlides)) * Float(_layout.value) computedWidth = (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 thumbViewLayer.frame.size.width = thumbView.frame.size.width checkPositions() updateActiveOverlays() } // Incomplete set moves a shorter distance than the standard increment value. // Update active overlay frames according to thumb position. private func updateActiveOverlays() { // adjusting thumb position if it goes beyond trackView on left/right. 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 leftActiveOverlayLayer.frame = .init(origin: .init(x: 0, y: 20), size: .init(width: leftActiveOverlay.frame.size.width, height: trackViewHeight)) //right 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 rightActiveOverlayLayer.frame = .init(origin: .init(x: 0, y: 20), size: .init(width: rightActiveOverlay.frame.size.width, height: trackViewHeight)) } private func checkPositions() { totalPositions = Int (ceil (Double(numberOfSlides) / Double(_layout.value))) } private func scrollThumbToPosition(_ position: Int) { setThumb(at: position) onScrubberDragPublisher.send(position) } private func setThumb(at position: Int) { UIView.animate(withDuration: 0.1, delay: 0.0, options: .curveLinear, animations: { [weak self] in guard let self else { return } self.thumbView.frame.origin.x = CGFloat(Float((position) - 1) * self.computedWidth) + self.trackView.frame.origin.x self.updateActiveOverlays() }) } //-------------------------------------------------- // MARK: - Gesture Methods //-------------------------------------------------- // 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 onScrubberChange(_ sender: UIPanGestureRecognizer) { let translation = sender.translation(in: thumbView) if sender.state == UIGestureRecognizer.State.began { trayOriginalCenter = thumbView.center onThumbTouchStartPublisher.send(position) } else if sender.state == UIGestureRecognizer.State.changed { let draggedPositions = Int (ceil (Double(translation.x) / Double(computedWidth))) setThumb(at: position + draggedPositions) } else if sender.state == UIGestureRecognizer.State.cancelled || sender.state == UIGestureRecognizer.State.ended { let draggedPositions = Int (ceil (Double(translation.x) / Double(computedWidth))) position = ((position + draggedPositions) < 1) ? 1 : (position + draggedPositions) if sender.state == UIGestureRecognizer.State.ended { onThumbTouchEndPublisher.send(position) } } } // Move the scrollbar thumb to the left while tapping on the left side of the scrubber. @objc func onLeftViewLongPressRecognizer(_ gesture: UILongPressGestureRecognizer) { animateOverlay(layer: leftActiveOverlayLayer, with: gesture, onGestureEnd: movePositionBackward) } // Move the scrollbar thumb to the right while tapping on the right side of the scrubber. @objc func onRightViewLongPressRecognizer(_ gesture: UILongPressGestureRecognizer) { animateOverlay(layer: rightActiveOverlayLayer, with: gesture, onGestureEnd: movePositionForward) } private func animateOverlay(layer: CALayer, with gesture: UILongPressGestureRecognizer, onGestureEnd: @escaping(() -> Void)) { UIView.animate(withDuration: 0.1, delay: 0.0, options: .curveLinear, animations: { [weak self] in guard let self else { return } if gesture.state == .began { layer.backgroundColor = activeOverlayColorConfiguration.getColor(self).cgColor layer.opacity = activeOpacity } else if gesture.state == .cancelled { layer.backgroundColor = UIColor.clear.cgColor layer.opacity = defaultOpacity } else if gesture.state == .ended { layer.backgroundColor = UIColor.clear.cgColor layer.opacity = defaultOpacity onGestureEnd() } }) } }