diff --git a/VDS.xcodeproj/project.pbxproj b/VDS.xcodeproj/project.pbxproj index 40a96b5a..580022a0 100644 --- a/VDS.xcodeproj/project.pbxproj +++ b/VDS.xcodeproj/project.pbxproj @@ -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 = ""; }; 18450CF02BA1B19C009FDF2A /* BreadcrumbsChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = BreadcrumbsChangeLog.txt; sourceTree = ""; }; 1855EC652BAABF2A002ACAC2 /* BreadcrumbItemModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreadcrumbItemModel.swift; sourceTree = ""; }; + 1808BEBB2BA41C3200129230 /* CarouselScrollbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselScrollbar.swift; sourceTree = ""; }; + 1808BEBF2BA456B700129230 /* CarouselScrollbarChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = CarouselScrollbarChangeLog.txt; sourceTree = ""; }; 186B2A892B88DA7F001AB71F /* TextAreaChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = TextAreaChangeLog.txt; sourceTree = ""; }; 18792A8F2B7431F2008C0D29 /* ButtonIconBadgeIndicatorModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonIconBadgeIndicatorModel.swift; sourceTree = ""; }; 18A65A012B96E848006602CC /* Breadcrumbs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Breadcrumbs.swift; sourceTree = ""; }; @@ -406,6 +410,15 @@ path = Breadcrumbs; sourceTree = ""; }; + 1808BEBA2BA41B1D00129230 /* CarouselScrollbar */ = { + isa = PBXGroup; + children = ( + 1808BEBB2BA41C3200129230 /* CarouselScrollbar.swift */, + 1808BEBF2BA456B700129230 /* CarouselScrollbarChangeLog.txt */, + ); + path = CarouselScrollbar; + sourceTree = ""; + }; 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 */, diff --git a/VDS/Components/CarouselScrollbar/CarouselScrollbar.swift b/VDS/Components/CarouselScrollbar/CarouselScrollbar.swift new file mode 100644 index 00000000..5f99c6b7 --- /dev/null +++ b/VDS/Components/CarouselScrollbar/CarouselScrollbar.swift @@ -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() + 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() + 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() + 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() + 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() + 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() + } + }) + } +} diff --git a/VDS/Components/CarouselScrollbar/CarouselScrollbarChangeLog.txt b/VDS/Components/CarouselScrollbar/CarouselScrollbarChangeLog.txt new file mode 100644 index 00000000..68179e36 --- /dev/null +++ b/VDS/Components/CarouselScrollbar/CarouselScrollbarChangeLog.txt @@ -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. + diff --git a/VDS/VDS.docc/VDS.md b/VDS/VDS.docc/VDS.md index 551a835c..63956f83 100755 --- a/VDS/VDS.docc/VDS.md +++ b/VDS/VDS.docc/VDS.md @@ -25,6 +25,7 @@ Using the system allows designers and developers to collaborate more easily and - ``Button`` - ``ButtonIcon`` - ``ButtonGroup`` +- ``CarouselScrollbar`` - ``Checkbox`` - ``CheckboxItem`` - ``CheckboxGroup``