From 77288e4c547cf8a2248912ec5bce506c62fbc604 Mon Sep 17 00:00:00 2001 From: Vasavi Kanamarlapudi Date: Thu, 4 Jul 2024 20:24:33 +0530 Subject: [PATCH] Digital ACT-191 ONEAPP-7013 story: slot alignment and rendering data --- VDS.xcodeproj/project.pbxproj | 10 +- VDS/Components/Carousel/Carousel.swift | 157 +++++++++++++++--- .../Carousel/CarouselRenderItemStyle.swift | 33 ++++ .../Carousel/CarouselSlotAlignmentModel.swift | 14 +- .../Carousel/CarouselSlotItemModel.swift | 31 ++++ 5 files changed, 212 insertions(+), 33 deletions(-) create mode 100644 VDS/Components/Carousel/CarouselRenderItemStyle.swift create mode 100644 VDS/Components/Carousel/CarouselSlotItemModel.swift diff --git a/VDS.xcodeproj/project.pbxproj b/VDS.xcodeproj/project.pbxproj index 4c72a920..64ec60bb 100644 --- a/VDS.xcodeproj/project.pbxproj +++ b/VDS.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 18013CED2C355BF900907F18 /* CarouselSlotItemModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18013CEC2C355BF900907F18 /* CarouselSlotItemModel.swift */; }; + 18013CEF2C355C5200907F18 /* CarouselRenderItemStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18013CEE2C355C5200907F18 /* CarouselRenderItemStyle.swift */; }; 1808BEBC2BA41C3200129230 /* CarouselScrollbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1808BEBB2BA41C3200129230 /* CarouselScrollbar.swift */; }; 1832AC572BA0791D008AE476 /* BreadcrumbCellItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1832AC562BA0791D008AE476 /* BreadcrumbCellItem.swift */; }; 1842B1DF2BECE28B0021AFCA /* CalendarDateViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1842B1DE2BECE28B0021AFCA /* CalendarDateViewCell.swift */; }; @@ -19,11 +21,9 @@ 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 */; }; - 18AE87542C06FE610075F181 /* CarouselChangeLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = 18AE87532C06FE610075F181 /* CarouselChangeLog.txt */; }; 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 */; }; - 18BDEE822B75316E00452358 /* ButtonIconChangeLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = 18BDEE812B75316E00452358 /* ButtonIconChangeLog.txt */; }; 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 */; }; @@ -209,6 +209,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 18013CEC2C355BF900907F18 /* CarouselSlotItemModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselSlotItemModel.swift; sourceTree = ""; }; + 18013CEE2C355C5200907F18 /* CarouselRenderItemStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselRenderItemStyle.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 = ""; }; 1832AC562BA0791D008AE476 /* BreadcrumbCellItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreadcrumbCellItem.swift; sourceTree = ""; }; @@ -502,6 +504,8 @@ 18AE874F2C06FDA60075F181 /* Carousel.swift */, 18B9763E2C11BA4A009271DF /* CarouselPaginationModel.swift */, 18B42AC52C09D197008D6262 /* CarouselSlotAlignmentModel.swift */, + 18013CEC2C355BF900907F18 /* CarouselSlotItemModel.swift */, + 18013CEE2C355C5200907F18 /* CarouselRenderItemStyle.swift */, 18AE87532C06FE610075F181 /* CarouselChangeLog.txt */, ); path = Carousel; @@ -1363,6 +1367,7 @@ EAC71A1D2A2E155A00E47A9F /* Checkbox.swift in Sources */, EAF7F0AB289B13FD00B287F5 /* TextStyleLabelAttribute.swift in Sources */, 18AE87502C06FDA60075F181 /* Carousel.swift in Sources */, + 18013CEF2C355C5200907F18 /* CarouselRenderItemStyle.swift in Sources */, EAB1D29C28A5618900DAE764 /* RadioButtonGroup.swift in Sources */, EA81410B2A0E8E3C004F60D2 /* ButtonIcon.swift in Sources */, EA985BE629688F6A00F2FF2E /* TileletBadgeModel.swift in Sources */, @@ -1405,6 +1410,7 @@ EA596ABF2A16B4F500300C4B /* Tabs.swift in Sources */, EAD062A72A3B67770015965D /* UIView+CALayer.swift in Sources */, EAD068942A560C13002E3A2D /* LoaderLaunchable.swift in Sources */, + 18013CED2C355BF900907F18 /* CarouselSlotItemModel.swift in Sources */, 18FEA1AD2BDD137500A56439 /* CalendarIndicatorModel.swift in Sources */, EA985BEC2968A91200F2FF2E /* TitleLockupTitleModel.swift in Sources */, 5FC35BE328D51405004EBEAC /* Button.swift in Sources */, diff --git a/VDS/Components/Carousel/Carousel.swift b/VDS/Components/Carousel/Carousel.swift index dbc10d98..a3cb1f1c 100644 --- a/VDS/Components/Carousel/Carousel.swift +++ b/VDS/Components/Carousel/Carousel.swift @@ -33,21 +33,6 @@ open class Carousel: View { //-------------------------------------------------- // MARK: - Enums //-------------------------------------------------- - /// Space between each tile. The default value will be 24px (6X) in tablet and 12px (3X) in mobile. - public enum Gutter: String, CaseIterable { - case twelvePX = "12px" - case twentyFourPX = "24px" - - var value: CGFloat { - switch self { - case .twelvePX: - VDSLayout.space3X - case .twentyFourPX: - VDSLayout.space6X - } - } - } - /// Enum used to describe the pagination display for this component. public enum PaginationDisplay: String, CaseIterable { case persistent, none @@ -75,6 +60,21 @@ open class Carousel: View { case value(CGFloat) } + /// Space between each tile. The default value will be 24px (6X) in tablet and 12px (3X) in mobile. + public enum Gutter: String, CaseIterable { + case twelvePX = "12px" + case twentyFourPX = "24px" + + var value: CGFloat { + switch self { + case .twelvePX: + VDSLayout.space3X + case .twentyFourPX: + VDSLayout.space6X + } + } + } + //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- @@ -123,11 +123,12 @@ open class Carousel: View { get { return _layout } set { _layout = newValue + carouselScrollBar.position = 0 setNeedsUpdate() } } - /// A callback when moving the carousel. Returns event object and selectedGroupIndex. + /// A callback when moving the carousel. Returns initial visible slide's index in the carousel. open var onChange: ((Int) -> Void)? { get { nil } set { @@ -182,7 +183,42 @@ open class Carousel: View { open var selectedIndex: Int? { didSet { setNeedsUpdate() } } /// If provided, will set the alignment for slot content when the slots has different heights. - open var slotAlignment: [CarouselSlotAlignmentModel] = [] { didSet { setNeedsUpdate() } } + open var slotAlignment: CarouselSlotAlignmentModel? { + get { return _slotAlignment } + set { + if let newValue { + _slotAlignment = newValue + } else { + _slotAlignment = nil + } + setNeedsUpdate() + } + } + + /// Render item style. If provided, the slot gets the background, width, height, border-radius. + open var renderItemStyle: CarouselRenderItemStyle? { + get { return _renderItemStyle } + set { + if let newValue { + _renderItemStyle = newValue + } else { + _renderItemStyle = nil + } + setNeedsUpdate() + } + } + + /// Render item. It passes a data array object and expects the styled component to apply in return. + open var renderItem: CarouselSlotItemModel? { + get { _renderItem } + set { + if let newValue { + _renderItem = newValue + } else { + _renderItem = nil + } + } + } //-------------------------------------------------- // MARK: - Private Properties @@ -250,9 +286,12 @@ open class Carousel: View { internal var _gutter: Gutter = UIDevice.isIPad ? .twentyFourPX : .twelvePX internal var _peek: Peek = .standard internal var _numberOfSlides: Int = 1 + internal var _slotAlignment: CarouselSlotAlignmentModel? = nil + internal var _renderItemStyle: CarouselRenderItemStyle? = nil + internal var _renderItem: CarouselSlotItemModel? = nil private var _width: Width? = nil - private var selectedGroupIndex: Int? { didSet { setNeedsUpdate() } } + private var selectedGroupIndex: Int? = nil private var containerStackHeightConstraint: NSLayoutConstraint? private var containerViewHeightConstraint: NSLayoutConstraint? private var prevButtonLeadingConstraint: NSLayoutConstraint? @@ -323,6 +362,7 @@ open class Carousel: View { .heightGreaterThanEqualTo(containerSize.height) addlisteners() + updatePaginationInset() } /// Used to make changes to the View based off a change events or from local properties. @@ -348,7 +388,9 @@ open class Carousel: View { carouselScrollBar.numberOfSlides = data.count carouselScrollBar.layout = _layout - carouselScrollBar.position = (carouselScrollBar.position == 0 || carouselScrollBar.position > carouselScrollBar.numberOfSlides) ? 1 : carouselScrollBar.position + 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. @@ -392,31 +434,32 @@ open class Carousel: View { nextButton.onClick = { _ in self.nextButtonClick() } previousButton.onClick = { _ in self.previousButtonClick() } - /// Will be called when the thumb move forward. + /// 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 thumb move backward. + /// 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 thumb touch start. + /// 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 thumb touch end. + /// 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() @@ -428,6 +471,7 @@ open class Carousel: View { 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 @@ -438,6 +482,7 @@ open class Carousel: View { } } + // Add carousel slots and load data if any private func addCarouselSlots() { getSlotWidth() if containerView.frame.size.width > 0 { @@ -454,6 +499,8 @@ open class Carousel: View { if data.count > 0 { var xPos = 0.0 for x in 0...data.count - 1 { + + // Add Carousel Slot let carouselSlot = View().with { $0.clipsToBounds = true $0.backgroundColor = UIColor(red: CGFloat(216) / 255.0, green: CGFloat(218) / 255.0, blue: CGFloat(218) / 255.0, alpha: 1) @@ -470,6 +517,29 @@ open class Carousel: View { .height(slotHeight) carouselSlot.layer.cornerRadius = 12.0 xPos = xPos + minimumSlotWidth + gutter.value + + // Add subview for content to Carousel Slot + let contentView = View().with { + $0.clipsToBounds = true + $0.backgroundColor = UIColor(red: CGFloat(216) / 255.0, green: CGFloat(218) / 255.0, blue: CGFloat(218) / 255.0, alpha: 1) + } + carouselSlot.addSubview(contentView) + + // Add received component + let item : CarouselSlotItemModel = .init(style: renderItemStyle, component: data[x] as? UIView) + if let component = item.component { + if slotAlignment != nil { + setSlotAlignment(contentView: contentView, parentView: carouselSlot) + } else { + contentView.pinToSuperView() + } + contentView.addSubview(component) + component.pinToSuperView() + contentView.layer.cornerRadius = component.layer.cornerRadius + if var surfacedView = component as? Surfaceable { + surfacedView.surface = surface + } + } } scrollView.contentSize = CGSize(width: xPos - gutter.value, height: slotHeight) } @@ -487,6 +557,37 @@ open class Carousel: View { } } + // Set slot alignment if provided. Used only when slot content have different heights or widths. + private func setSlotAlignment(contentView: View, parentView: View) { + parentView.backgroundColor = .clear + switch slotAlignment?.vertical { + case .top: + contentView.topAnchor.constraint(equalTo: parentView.topAnchor).activate() + break + case .middle: + contentView.centerYAnchor.constraint(equalTo: parentView.centerYAnchor).activate() + break + case .bottom: + contentView.bottomAnchor.constraint(equalTo: parentView.bottomAnchor).activate() + break + default: break + } + + switch slotAlignment?.horizontal { + case .left: + contentView.leadingAnchor.constraint(equalTo: parentView.leadingAnchor).activate() + break + case .center: + contentView.centerXAnchor.constraint(equalTo: parentView.centerXAnchor).activate() + break + case .right: + parentView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).activate() + break + default: break + } + } + + // Get the slot width relative to the peak private func getSlotWidth() { let actualWidth = containerView.frame.size.width let isScrollbarSuppressed = data.count > 0 && layout.value == data.count @@ -505,7 +606,7 @@ open class Carousel: View { 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 + minimumSlotWidth = isPeekMinimumOnTablet ? minimumSlotWidth : minimumSlotWidth - peekMinimum - gutter.value case .none: break } @@ -525,6 +626,7 @@ open class Carousel: View { updateScrollPosition(position: carouselScrollBar.position, callbackText:"pageControlClicks") } + // The size of slot depends on the selected aspect ratio private func ratioSize(for width: CGFloat) -> CGSize { var height: CGFloat = width @@ -575,6 +677,7 @@ open class Carousel: View { 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 @@ -598,7 +701,7 @@ open class Carousel: View { xPos = xPosition - gutter.value - (minimumSlotWidth/4)/2 } case .minimum: - xPos = isPeekMinimumOnTablet ? xPosition : xPosition - peekMinimum/2 + xPos = isPeekMinimumOnTablet ? xPosition : xPosition - peekMinimum case .none: xPos = xPosition } @@ -608,9 +711,13 @@ open class Carousel: View { let yPos = scrollView.contentOffset.y scrollView.setContentOffset(CGPoint(x: xPos, y: yPos), animated: true) showPaginationControls() + selectedIndex = ((position-1) * layout.value) + 1 + onChangePublisher.send(selectedIndex ?? 1) + selectedGroupIndex = position } } + // 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))) } diff --git a/VDS/Components/Carousel/CarouselRenderItemStyle.swift b/VDS/Components/Carousel/CarouselRenderItemStyle.swift new file mode 100644 index 00000000..c447dfa1 --- /dev/null +++ b/VDS/Components/Carousel/CarouselRenderItemStyle.swift @@ -0,0 +1,33 @@ +// +// CarouselRenderItemStyle.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 30/06/24. +// + +import Foundation +import UIKit +import VDSCoreTokens + +/// A custom data type that holds the style props if provided any. +public struct CarouselRenderItemStyle { + + /// BackgroundColor for slot + public let backgroundColor: String? + + /// Height for slot + public var height: CGFloat? + + /// BorderRadius for slot + public var borderRadius: CGFloat? + + /// Width for slot + public var width: CGFloat? + + public init(backgroundColor: String?, height: CGFloat?, width: CGFloat?, borderRadius: CGFloat?) { + self.backgroundColor = backgroundColor + self.height = height + self.borderRadius = borderRadius ?? 12.0 + self.width = width + } +} diff --git a/VDS/Components/Carousel/CarouselSlotAlignmentModel.swift b/VDS/Components/Carousel/CarouselSlotAlignmentModel.swift index 5a8ec661..ee1d491b 100644 --- a/VDS/Components/Carousel/CarouselSlotAlignmentModel.swift +++ b/VDS/Components/Carousel/CarouselSlotAlignmentModel.swift @@ -7,17 +7,19 @@ import Foundation -/// Custom data type for slotAlignment prop for 'Carousel' component. +/// 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 { - /// Text that shown to an indicator for legend - public var vertical: String + /// Used for vertical alignment of slot alignment. + public var vertical: Carousel.Vertical - /// Date to an indicator - public var horizontal: String + /// Used for horizontal alignment of slot alignment. + public var horizontal: Carousel.Horizontal - public init(vertical: String, horizontal: String) { + public init(vertical: Carousel.Vertical, horizontal: Carousel.Horizontal) { self.vertical = vertical self.horizontal = horizontal } diff --git a/VDS/Components/Carousel/CarouselSlotItemModel.swift b/VDS/Components/Carousel/CarouselSlotItemModel.swift new file mode 100644 index 00000000..3830b29f --- /dev/null +++ b/VDS/Components/Carousel/CarouselSlotItemModel.swift @@ -0,0 +1,31 @@ +// +// CarouselSlotItemModel.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 30/06/24. +// + +import Foundation +import UIKit +import VDSCoreTokens + +/// A custom data type that holds the style and component for a slot of the 'Carousel' component. +public struct CarouselSlotItemModel { + + /// Style props if provided any + public var style: CarouselRenderItemStyle? + + /// Component to be show on Carousel slot + public var component: UIView? + + public init(style: CarouselRenderItemStyle? = nil, component: UIView? = nil) { + self.style = style + self.component = component + if let color = style?.backgroundColor { + self.component?.backgroundColor = .init(hexString: color) + } + if let borderRadius = style?.borderRadius { + self.component?.layer.cornerRadius = borderRadius + } + } +}