Merge branch 'vasavk/carouselScrollbar' into 'develop'

VDS Brand 3.0 Carousel Scrollbar for IOS

See merge request BPHV_MIPS/vds_ios!188
This commit is contained in:
Bruce, Matt R 2024-03-27 20:30:22 +00:00
commit 51c94b96cb
4 changed files with 481 additions and 0 deletions

View File

@ -10,6 +10,8 @@
1832AC572BA0791D008AE476 /* BreadcrumbCellItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1832AC562BA0791D008AE476 /* BreadcrumbCellItem.swift */; };
18450CF12BA1B19C009FDF2A /* BreadcrumbsChangeLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = 18450CF02BA1B19C009FDF2A /* BreadcrumbsChangeLog.txt */; };
1855EC662BAABF2A002ACAC2 /* BreadcrumbItemModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1855EC652BAABF2A002ACAC2 /* BreadcrumbItemModel.swift */; };
1808BEBC2BA41C3200129230 /* CarouselScrollbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1808BEBB2BA41C3200129230 /* CarouselScrollbar.swift */; };
1808BEC02BA456B700129230 /* CarouselScrollbarChangeLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = 1808BEBF2BA456B700129230 /* CarouselScrollbarChangeLog.txt */; };
186B2A8A2B88DA7F001AB71F /* TextAreaChangeLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = 186B2A892B88DA7F001AB71F /* TextAreaChangeLog.txt */; };
18792A902B7431F2008C0D29 /* ButtonIconBadgeIndicatorModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18792A8F2B7431F2008C0D29 /* ButtonIconBadgeIndicatorModel.swift */; };
18A65A022B96E848006602CC /* Breadcrumbs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A65A012B96E848006602CC /* Breadcrumbs.swift */; };
@ -197,6 +199,8 @@
1832AC562BA0791D008AE476 /* BreadcrumbCellItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreadcrumbCellItem.swift; sourceTree = "<group>"; };
18450CF02BA1B19C009FDF2A /* BreadcrumbsChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = BreadcrumbsChangeLog.txt; sourceTree = "<group>"; };
1855EC652BAABF2A002ACAC2 /* BreadcrumbItemModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreadcrumbItemModel.swift; sourceTree = "<group>"; };
1808BEBB2BA41C3200129230 /* CarouselScrollbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselScrollbar.swift; sourceTree = "<group>"; };
1808BEBF2BA456B700129230 /* CarouselScrollbarChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = CarouselScrollbarChangeLog.txt; sourceTree = "<group>"; };
186B2A892B88DA7F001AB71F /* TextAreaChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = TextAreaChangeLog.txt; sourceTree = "<group>"; };
18792A8F2B7431F2008C0D29 /* ButtonIconBadgeIndicatorModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonIconBadgeIndicatorModel.swift; sourceTree = "<group>"; };
18A65A012B96E848006602CC /* Breadcrumbs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Breadcrumbs.swift; sourceTree = "<group>"; };
@ -406,6 +410,15 @@
path = Breadcrumbs;
sourceTree = "<group>";
};
1808BEBA2BA41B1D00129230 /* CarouselScrollbar */ = {
isa = PBXGroup;
children = (
1808BEBB2BA41C3200129230 /* CarouselScrollbar.swift */,
1808BEBF2BA456B700129230 /* CarouselScrollbarChangeLog.txt */,
);
path = CarouselScrollbar;
sourceTree = "<group>";
};
445BA07629C07ABA0036A7C5 /* Notification */ = {
isa = PBXGroup;
children = (
@ -554,6 +567,7 @@
EAD062AE2A3B87210015965D /* BadgeIndicator */,
18A65A002B96E7E1006602CC /* Breadcrumbs */,
EA0FC2BE2912D18200DF80B4 /* Buttons */,
1808BEBA2BA41B1D00129230 /* CarouselScrollbar */,
EAF7F092289985E200B287F5 /* Checkbox */,
EA985BF3296C609E00F2FF2E /* Icon */,
EA3362412892EF700071C351 /* Label */,
@ -1033,6 +1047,7 @@
EAEEECA22B1F92AD00531FC2 /* LabelChangeLog.txt in Resources */,
EA3362072891E14D0071C351 /* VerizonNHGeDS-Regular.otf in Resources */,
EAEEEC9A2B1F8E4400531FC2 /* TextLinkChangeLog.txt in Resources */,
1808BEC02BA456B700129230 /* CarouselScrollbarChangeLog.txt in Resources */,
EAEEECAF2B1FC2BA00531FC2 /* ToggleViewChangeLog.txt in Resources */,
EAEEEC922B1F807300531FC2 /* BadgeChangeLog.txt in Resources */,
EAEEEC9E2B1F8F7700531FC2 /* ButtonGroupChangeLog.txt in Resources */,
@ -1162,6 +1177,7 @@
EA3361AF288B26310071C351 /* FormFieldable.swift in Sources */,
EA513A952A4E1F82002A4DFF /* TitleLockupStyleConfiguration.swift in Sources */,
44604AD729CE196600E62B51 /* Line.swift in Sources */,
1808BEBC2BA41C3200129230 /* CarouselScrollbar.swift in Sources */,
EAF978212A99035B00C2FEA9 /* Enabling.swift in Sources */,
EA5E3058295105A40082B959 /* Tilelet.swift in Sources */,
EA89201528B56CF4006B9984 /* RadioBoxGroup.swift in Sources */,

View File

@ -0,0 +1,436 @@
//
// 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 {
_numberOfSlides = newValue
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 {
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)? {
get { nil }
set {
onScrubberDragCancellable?.cancel()
if let newValue {
onScrubberDragCancellable = onScrubberDragPublisher
.sink { c in
newValue(c)
}
}
}
}
/// A publisher for when the scrubber position changes. Passes parameters (position).
open var onScrubberDragPublisher = PassthroughSubject<Int, Never>()
private var onScrubberDragCancellable: AnyCancellable?
/// A callback when the thumb move forward. Passes parameters (position).
open var onMoveForward: ((Int) -> Void)? {
get { nil }
set {
onMoveForwardCancellable?.cancel()
if let newValue {
onMoveForwardCancellable = onMoveForwardPublisher
.sink { c in
newValue(c)
}
}
}
}
/// A publisher for when the thumb move forward. Passes parameters (position).
open var onMoveForwardPublisher = PassthroughSubject<Int, Never>()
private var onMoveForwardCancellable: AnyCancellable?
/// A callback when the thumb move backward. Passes parameters (position).
open var onMoveBackward: ((Int) -> Void)? {
get { nil }
set {
onMoveBackwardCancellable?.cancel()
if let newValue {
onMoveBackwardCancellable = onMoveBackwardPublisher
.sink { c in
newValue(c)
}
}
}
}
/// A publisher for when the thumb move backward. Passes parameters (position).
open var onMoveBackwardPublisher = PassthroughSubject<Int, Never>()
private var onMoveBackwardCancellable: AnyCancellable?
/// A callback when the thumb touch start. Passes parameters (position).
open var onThumbTouchStart: ((Int) -> Void)? {
get { nil }
set {
onThumbTouchStartCancellable?.cancel()
if let newValue {
onThumbTouchStartCancellable = onThumbTouchStartPublisher
.sink { c in
newValue(c)
}
}
}
}
/// A publisher for when the thumb touch start. Passes parameters (position).
open var onThumbTouchStartPublisher = PassthroughSubject<Int, Never>()
private var onThumbTouchStartCancellable: AnyCancellable?
/// A callback when the thumb touch end. Passes parameters (position).
open var onThumbTouchEnd: ((Int) -> Void)? {
get { nil }
set {
onThumbTouchEndCancellable?.cancel()
if let newValue {
onThumbTouchEndCancellable = onThumbTouchEndPublisher
.sink { c in
newValue(c)
}
}
}
}
/// A publisher for when the thumb touch end. Passes parameters (position).
open var onThumbTouchEndPublisher = PassthroughSubject<Int, Never>()
private var onThumbTouchEndCancellable: AnyCancellable?
//--------------------------------------------------
// 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 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 initialSetup() {
super.initialSetup()
}
open override func setup() {
super.setup()
isAccessibilityElement = true
accessibilityLabel = "Carousel Scrollbar"
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 updateView() {
super.updateView()
trackView.backgroundColor = trackColorConfiguration.getColor(surface)
thumbViewLayer.backgroundColor = thumbColorConfiguration.getColor(surface).cgColor
}
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 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(_selectedLayout.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(_selectedLayout.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()
}
})
}
}

View File

@ -0,0 +1,28 @@
MM/DD/YYYY
----------------
07/29/22
----------------
- Initial Brand 3.0 handoff
08/10/2022
----------------
- Updated default and inverted prop to light and dark surface.
11/30/2022
----------------
- Added "(web only)" to any instance of "keyboard focus"
12/13/2022
----------------
- Replaced focus border pixel and style & spacing values with tokens.
01/09/2023
----------------
- Updated Specs to use new SPEC Templates and SPEC DOC Components.
05/19/2023
----------------
- Changed Carousel Scrubber to Carousel Scrollbar and replaced all instances of Scrubber to Scrollbar.
- Removed KF states and behaviors from States and Behaviors > Interaction Types.

View File

@ -25,6 +25,7 @@ Using the system allows designers and developers to collaborate more easily and
- ``Button``
- ``ButtonIcon``
- ``ButtonGroup``
- ``CarouselScrollbar``
- ``Checkbox``
- ``CheckboxItem``
- ``CheckboxGroup``