diff --git a/VDS.xcodeproj/project.pbxproj b/VDS.xcodeproj/project.pbxproj index ee0265b4..59600512 100644 --- a/VDS.xcodeproj/project.pbxproj +++ b/VDS.xcodeproj/project.pbxproj @@ -18,7 +18,10 @@ 18A3F12A2BD9298900498E4A /* Calendar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A3F1292BD9298900498E4A /* Calendar.swift */; }; 18A65A022B96E848006602CC /* Breadcrumbs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A65A012B96E848006602CC /* Breadcrumbs.swift */; }; 18A65A042B96F050006602CC /* BreadcrumbItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A65A032B96F050006602CC /* BreadcrumbItem.swift */; }; + 18AE87502C06FDA60075F181 /* Carousel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AE874F2C06FDA60075F181 /* Carousel.swift */; }; + 18B42AC62C09D197008D6262 /* CarouselSlotAlignmentModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18B42AC52C09D197008D6262 /* CarouselSlotAlignmentModel.swift */; }; 18B463A42BBD3C46005C4528 /* DropdownOptionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18B463A32BBD3C46005C4528 /* DropdownOptionModel.swift */; }; + 18B9763F2C11BA4A009271DF /* CarouselPaginationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18B9763E2C11BA4A009271DF /* CarouselPaginationModel.swift */; }; 18FEA1AD2BDD137500A56439 /* CalendarIndicatorModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18FEA1AC2BDD137500A56439 /* CalendarIndicatorModel.swift */; }; 18FEA1B52BE0E63600A56439 /* Date+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18FEA1B42BE0E63600A56439 /* Date+Extension.swift */; }; 445BA07829C07B3D0036A7C5 /* Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 445BA07729C07B3D0036A7C5 /* Notification.swift */; }; @@ -219,7 +222,11 @@ 18A3F1292BD9298900498E4A /* Calendar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Calendar.swift; sourceTree = ""; }; 18A65A012B96E848006602CC /* Breadcrumbs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Breadcrumbs.swift; sourceTree = ""; }; 18A65A032B96F050006602CC /* BreadcrumbItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreadcrumbItem.swift; sourceTree = ""; }; + 18AE874F2C06FDA60075F181 /* Carousel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Carousel.swift; sourceTree = ""; }; + 18AE87532C06FE610075F181 /* CarouselChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = CarouselChangeLog.txt; sourceTree = ""; }; + 18B42AC52C09D197008D6262 /* CarouselSlotAlignmentModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselSlotAlignmentModel.swift; sourceTree = ""; }; 18B463A32BBD3C46005C4528 /* DropdownOptionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropdownOptionModel.swift; sourceTree = ""; }; + 18B9763E2C11BA4A009271DF /* CarouselPaginationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselPaginationModel.swift; sourceTree = ""; }; 18BDEE812B75316E00452358 /* ButtonIconChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = ButtonIconChangeLog.txt; sourceTree = ""; }; 18FEA1AC2BDD137500A56439 /* CalendarIndicatorModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarIndicatorModel.swift; sourceTree = ""; }; 18FEA1B42BE0E63600A56439 /* Date+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extension.swift"; sourceTree = ""; }; @@ -487,6 +494,17 @@ path = Breadcrumbs; sourceTree = ""; }; + 18AE874E2C06FD610075F181 /* Carousel */ = { + isa = PBXGroup; + children = ( + 18AE874F2C06FDA60075F181 /* Carousel.swift */, + 18B9763E2C11BA4A009271DF /* CarouselPaginationModel.swift */, + 18B42AC52C09D197008D6262 /* CarouselSlotAlignmentModel.swift */, + 18AE87532C06FE610075F181 /* CarouselChangeLog.txt */, + ); + path = Carousel; + sourceTree = ""; + }; 440B84C82BD8E0CE004A732A /* Table */ = { isa = PBXGroup; children = ( @@ -657,6 +675,7 @@ 18A65A002B96E7E1006602CC /* Breadcrumbs */, EA0FC2BE2912D18200DF80B4 /* Buttons */, 18A3F1202BD8F5DE00498E4A /* Calendar */, + 18AE874E2C06FD610075F181 /* Carousel */, 1808BEBA2BA41B1D00129230 /* CarouselScrollbar */, EAF7F092289985E200B287F5 /* Checkbox */, EAC58C1F2BF127F000BA39FA /* DatePicker */, @@ -1290,6 +1309,7 @@ EA5F86D02A1F936100BC83E4 /* TabsContainer.swift in Sources */, EAF7F0B1289B177F00B287F5 /* ColorLabelAttribute.swift in Sources */, EAC9258F2911C9DE00091998 /* EntryFieldBase.swift in Sources */, + 18B9763F2C11BA4A009271DF /* CarouselPaginationModel.swift in Sources */, EAB1D2EA28AE84AA00DAE764 /* UIControlPublisher.swift in Sources */, EAD068922A560B65002E3A2D /* LoaderViewController.swift in Sources */, 44BD43B62C04866600644F87 /* TableRowModel.swift in Sources */, @@ -1301,6 +1321,7 @@ EA8E40932A82889500934ED3 /* TooltipDialog.swift in Sources */, 44604AD429CE186A00E62B51 /* NotificationButtonModel.swift in Sources */, EAD8D2C128BFDE8B006EB6A6 /* UIGestureRecognizer+Publisher.swift in Sources */, + 18B42AC62C09D197008D6262 /* CarouselSlotAlignmentModel.swift in Sources */, 71B23C2D2B91FA690027F7D9 /* Pagination.swift in Sources */, EA0D1C372A681CCE00E5C127 /* ToggleView.swift in Sources */, EAF7F0B9289C139800B287F5 /* ColorConfiguration.swift in Sources */, @@ -1339,6 +1360,7 @@ EA0B18052A9E2D2D00F2D0CD /* SelectorBase.swift in Sources */, EAC71A1D2A2E155A00E47A9F /* Checkbox.swift in Sources */, EAF7F0AB289B13FD00B287F5 /* TextStyleLabelAttribute.swift in Sources */, + 18AE87502C06FDA60075F181 /* Carousel.swift in Sources */, EAB1D29C28A5618900DAE764 /* RadioButtonGroup.swift in Sources */, EA81410B2A0E8E3C004F60D2 /* ButtonIcon.swift in Sources */, EA985BE629688F6A00F2FF2E /* TileletBadgeModel.swift in Sources */, diff --git a/VDS/Components/Carousel/Carousel.swift b/VDS/Components/Carousel/Carousel.swift new file mode 100644 index 00000000..3e8aed2d --- /dev/null +++ b/VDS/Components/Carousel/Carousel.swift @@ -0,0 +1,565 @@ +// +// 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 + } + + /// Space between each tile. The default value will be 6X in tablet and 3X in mobile. + public enum Gutter: String, CaseIterable , DefaultValuing { + case gutter3X = "3X" + case gutter6X = "6X" + + public static var defaultValue: Self { UIDevice.isIPad ? .gutter6X : .gutter3X } + + public var value: CGFloat { + switch self { + case .gutter3X: + VDSLayout.space3X + case .gutter6X: + 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 6X in tablet and 3X in mobile. + open var gutter: Gutter = Gutter.defaultValue { 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 3X in tablet and 2X 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 groupIndex: Int = 0 { didSet { setNeedsUpdate() } } + + /// If provided, will set the alignment for slot content when the slots has different heights. + open var slotAlignment: CarouselSlotAlignmentModel? = .init(vertical: .top, horizontal: .left) { 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() + private var onChangeCancellable: AnyCancellable? + + private var containerStackHeightConstraint: NSLayoutConstraint? + private var containerViewHeightConstraint: NSLayoutConstraint? + private var prevButtonLeadingConstraint: NSLayoutConstraint? + private var nextButtonTrailingConstraint: 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 + + //-------------------------------------------------- + // 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 ? .gutter6X : .gutter3X + peek = .standard + } + + //-------------------------------------------------- + // MARK: - Private Methods + //-------------------------------------------------- + private func addlisteners() { + nextButton.onClick = { _ in self.nextButtonClick() } + previousButton.onClick = { _ in self.previousButtonClick() } + + /// Will be called when the scrubber position changes. + carouselScrollBar.onScrubberDrag = { [weak self] scrubberId in + guard let self else { return } + updateScrollPosition(position: scrubberId, callbackText:"onThumbPositionChange") + } + + /// 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(component: UIView, with itemWidth: CGFloat) -> CGFloat { + let maxSize = CGSize(width: itemWidth, height: CGFloat.greatestFiniteMagnitude) + let estItemSize = component.systemLayoutSizeFitting(maxSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) + return estItemSize.height + } + + private func fetchCarouselHeight() -> CGFloat { + var height = slotDefaultHeight + if views.count > 0 { + for index in 0...views.count - 1 { + let estHeight = estimateHeightFor(component: views[index], 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 index in 0...views.count - 1 { + + // Add Carousel Slot + let carouselSlot = View().with { + $0.clipsToBounds = true + } + scrollView.addSubview(carouselSlot) + scrollView.delegate = self + + carouselSlot + .pinTop() + .pinBottom() + .pinLeading(xPos) + .width(minimumSlotWidth) + .height(slotHeight) + xPos = xPos + minimumSlotWidth + gutter.value + + let component = views[index] + carouselSlot.addSubview(component) + setSlotAlignment(contentView: component) + } + 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: UIView) { + switch slotAlignment?.vertical { + case .top: + contentView + .pinTop() + .pinBottomLessThanOrEqualTo() + case .middle: + contentView + .pinTopGreaterThanOrEqualTo() + .pinBottomLessThanOrEqualTo() + .pinCenterY() + case .bottom: + contentView + .pinTopGreaterThanOrEqualTo() + .pinBottom() + default: break + } + + switch slotAlignment?.horizontal { + case .left: + contentView + .pinLeading() + .pinTrailingLessThanOrEqualTo() + case .center: + contentView + .pinLeadingGreaterThanOrEqualTo() + .pinTrailingLessThanOrEqualTo() + .pinCenterX() + case .right: + contentView + .pinLeadingGreaterThanOrEqualTo() + .pinTrailing() + 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 = ceil(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 slotWidthWithGutter = minimumSlotWidth + gutter.value + let xPosition = CGFloat( Float(position-1) * Float(layout.value) * Float(slotWidthWithGutter)) + let peekWidth = (containerView.frame.size.width - gutter.value - (Double(layout.value) * (minimumSlotWidth + gutter.value)))/2 + xPos = (peek == .none || isPeekMinimumOnTablet) ? xPosition : xPosition - gutter.value - peekWidth + } + } + carouselScrollBar.scrubberId = position+1 + let yPos = scrollView.contentOffset.y + scrollView.setContentOffset(CGPoint(x: xPos, y: yPos), animated: true) + showPaginationControls() + groupIndex = position-1 + onChangePublisher.send(groupIndex) + } + } + + // 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) { + updateScrollbarPosition(targetContentOffsetXPos: targetContentOffset.pointee.x) + } + +} diff --git a/VDS/Components/Carousel/CarouselChangeLog.txt b/VDS/Components/Carousel/CarouselChangeLog.txt new file mode 100644 index 00000000..a1fd0f4f --- /dev/null +++ b/VDS/Components/Carousel/CarouselChangeLog.txt @@ -0,0 +1,15 @@ +MM/DD/YYYY +---------------- + +06/22/2023 +---------------- +- Initial Beta Release + +10/02/2023 +---------------- +- Removed (Beta) from header. Removed deprecated sections and “New” badge from Kind section. + +11/20/2023 +---------------- +- Updated visuals to reflect new corner radius value - 12px +- Updated focus border corner radius to 14px diff --git a/VDS/Components/Carousel/CarouselPaginationModel.swift b/VDS/Components/Carousel/CarouselPaginationModel.swift new file mode 100644 index 00000000..ab85d6a4 --- /dev/null +++ b/VDS/Components/Carousel/CarouselPaginationModel.swift @@ -0,0 +1,26 @@ +// +// CarouselPaginationModel.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 06/06/24. +// + +import Foundation +import UIKit + +/// Custom data type for pagination prop for 'Carousel' component. +extension Carousel { + public struct CarouselPaginationModel { + + /// Pagination supports Button icon property 'kind'. + public var kind: ButtonIcon.Kind + + /// Pagination supports Button icon property 'floating'. + public var floating: Bool + + public init(kind: ButtonIcon.Kind, floating: Bool) { + self.kind = kind + self.floating = floating + } + } +} diff --git a/VDS/Components/Carousel/CarouselSlotAlignmentModel.swift b/VDS/Components/Carousel/CarouselSlotAlignmentModel.swift new file mode 100644 index 00000000..ee1d491b --- /dev/null +++ b/VDS/Components/Carousel/CarouselSlotAlignmentModel.swift @@ -0,0 +1,27 @@ +// +// CarouselSlotAlignmentModel.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 31/05/24. +// + +import Foundation + +/// Custom data type for the SlotAlignment prop for the 'carousel' component. +extension Carousel { + + /// Used only when slot content have different heights or widths. + public struct CarouselSlotAlignmentModel { + + /// Used for vertical alignment of slot alignment. + public var vertical: Carousel.Vertical + + /// Used for horizontal alignment of slot alignment. + public var horizontal: Carousel.Horizontal + + public init(vertical: Carousel.Vertical, horizontal: Carousel.Horizontal) { + self.vertical = vertical + self.horizontal = horizontal + } + } +} diff --git a/VDS/Components/CarouselScrollbar/CarouselScrollbar.swift b/VDS/Components/CarouselScrollbar/CarouselScrollbar.swift index b4213f32..1e05d875 100644 --- a/VDS/Components/CarouselScrollbar/CarouselScrollbar.swift +++ b/VDS/Components/CarouselScrollbar/CarouselScrollbar.swift @@ -45,13 +45,13 @@ open class CarouselScrollbar: View { } /// The number of slides that can appear at once in a set in a carousel container. - open var selectedLayout: Layout? { - get { return _selectedLayout } + open var layout: Layout? { + get { return _layout } set { if let newValue { - _selectedLayout = newValue + _layout = newValue } else { - _selectedLayout = .oneUP + _layout = .oneUP } setThumbWidth() scrollThumbToPosition(position) @@ -198,7 +198,7 @@ open class CarouselScrollbar: View { //-------------------------------------------------- // Sizes are from InVision design specs. internal var containerSize: CGSize { CGSize(width: 45, height: 44) } - internal var _selectedLayout: Layout = .oneUP + internal var _layout: Layout = .oneUP internal var _numberOfSlides: Int = 1 internal var totalPositions: Int = 1 internal var _position: Int = 1 @@ -329,7 +329,7 @@ open class CarouselScrollbar: View { // Compute track width and should maintain minimum thumb width if needed private func setThumbWidth() { - let width = (Float(trackViewWidth) / Float(numberOfSlides)) * Float(_selectedLayout.value) + 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) @@ -362,7 +362,7 @@ open class CarouselScrollbar: View { } private func checkPositions() { - totalPositions = Int (ceil (Double(numberOfSlides) / Double(_selectedLayout.value))) + totalPositions = Int (ceil (Double(numberOfSlides) / Double(_layout.value))) } private func scrollThumbToPosition(_ position: Int) { diff --git a/VDS/VDS.docc/VDS.md b/VDS/VDS.docc/VDS.md index d145ea0a..6582ce4a 100755 --- a/VDS/VDS.docc/VDS.md +++ b/VDS/VDS.docc/VDS.md @@ -26,6 +26,7 @@ Using the system allows designers and developers to collaborate more easily and - ``ButtonIcon`` - ``ButtonGroup`` - ``CalendarBase`` +- ``Carousel`` - ``CarouselScrollbar`` - ``Checkbox`` - ``CheckboxItem``