diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 6ceded2be3..16d91af0aa 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -29,6 +29,9 @@ 03A98D362D244329009BCA61 /* UIConfigDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A98D352D244321009BCA61 /* UIConfigDecodingTests.swift */; }; 03A98D382D2AC63B009BCA61 /* UIConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A98D372D2AC637009BCA61 /* UIConfigProvider.swift */; }; 03C06FC52D4553C000600693 /* PresentedPartialsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C06FC42D4553BB00600693 /* PresentedPartialsTests.swift */; }; + 03C06FC72D46742D00600693 /* PaywallCarouselComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C06FC62D46742600600693 /* PaywallCarouselComponent.swift */; }; + 03C06FCA2D479C7400600693 /* CarouselComponentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C06FC92D479C6D00600693 /* CarouselComponentView.swift */; }; + 03C06FCC2D479C7C00600693 /* CarouselComponentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C06FCB2D479C7600600693 /* CarouselComponentViewModel.swift */; }; 03C72F6D2D32CDFB00297FEC /* FamilySharingTogglePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C72F6C2D32CDFB00297FEC /* FamilySharingTogglePreview.swift */; }; 03C72F8D2D3311E300297FEC /* DisplayableColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C72F8C2D3311D500297FEC /* DisplayableColor.swift */; }; 03C72FBE2D34949600297FEC /* PaywallIconComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C72FBD2D34949600297FEC /* PaywallIconComponent.swift */; }; @@ -1313,6 +1316,9 @@ 03A98D352D244321009BCA61 /* UIConfigDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIConfigDecodingTests.swift; sourceTree = ""; }; 03A98D372D2AC637009BCA61 /* UIConfigProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIConfigProvider.swift; sourceTree = ""; }; 03C06FC42D4553BB00600693 /* PresentedPartialsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentedPartialsTests.swift; sourceTree = ""; }; + 03C06FC62D46742600600693 /* PaywallCarouselComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallCarouselComponent.swift; sourceTree = ""; }; + 03C06FC92D479C6D00600693 /* CarouselComponentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselComponentView.swift; sourceTree = ""; }; + 03C06FCB2D479C7600600693 /* CarouselComponentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselComponentViewModel.swift; sourceTree = ""; }; 03C72F6C2D32CDFB00297FEC /* FamilySharingTogglePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FamilySharingTogglePreview.swift; sourceTree = ""; }; 03C72F8C2D3311D500297FEC /* DisplayableColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayableColor.swift; sourceTree = ""; }; 03C72FBD2D34949600297FEC /* PaywallIconComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallIconComponent.swift; sourceTree = ""; }; @@ -2601,6 +2607,15 @@ path = RevenueCatUI; sourceTree = ""; }; + 03C06FC82D479C6300600693 /* Carousel */ = { + isa = PBXGroup; + children = ( + 03C06FC92D479C6D00600693 /* CarouselComponentView.swift */, + 03C06FCB2D479C7600600693 /* CarouselComponentViewModel.swift */, + ); + path = Carousel; + sourceTree = ""; + }; 03C72FC12D349BAE00297FEC /* Icon */ = { isa = PBXGroup; children = ( @@ -2673,6 +2688,7 @@ children = ( 2C7457472CEA66AB004ACE52 /* ComponentsView.swift */, 7707A94A2CAD936A006E0313 /* Button */, + 03C06FC82D479C6300600693 /* Carousel */, 88B1BAE62C813A3C001B7EE5 /* Image */, 03C72FC12D349BAE00297FEC /* Icon */, 2CC791542CC0452100FBE120 /* Packages */, @@ -4978,6 +4994,7 @@ 03C72FBD2D34949600297FEC /* PaywallIconComponent.swift */, 88E679462C7503C1007E69D5 /* PaywallStackComponent.swift */, 4D3BA4CB2D37D15E00668AFC /* PaywallTimelineComponent.swift */, + 03C06FC62D46742600600693 /* PaywallCarouselComponent.swift */, 03E37BE92D30B32200CD9678 /* PaywallTabsComponent.swift */, 88AD01072C740CF400AA1F2B /* PaywallTextComponent.swift */, 2C2AEB3A2CA7209F00A50F38 /* PaywallPackageComponent.swift */, @@ -6352,6 +6369,7 @@ B34605BD279A6E380031CA74 /* CallbackCacheStatus.swift in Sources */, 42F1DF385E3C1F9903A07FBF /* ProductsFetcherSK1.swift in Sources */, 805B60C97993B311CEC93EAF /* ProductsFetcherSK2.swift in Sources */, + 03C06FC72D46742D00600693 /* PaywallCarouselComponent.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -6818,6 +6836,7 @@ 2C7457482CEA66AB004ACE52 /* ComponentsView.swift in Sources */, 353756722C382C2800A1B8D6 /* URLUtilities.swift in Sources */, 57D8D5532D3EAF11009CB6ED /* CompatibilityLabeledContent.swift in Sources */, + 03C06FCA2D479C7400600693 /* CarouselComponentView.swift in Sources */, 353FDC0F2CA446FA0055F328 /* StoreProductDiscount+Extensions.swift in Sources */, 03A98CF12D222F5F009BCA61 /* FallbackComponentPreview.swift in Sources */, 887A60862C1D037000E1A461 /* FooterHidingModifier.swift in Sources */, @@ -6870,6 +6889,7 @@ 353756672C382C2800A1B8D6 /* PurchaseInformation.swift in Sources */, 887A60682C1D037000E1A461 /* TemplateError.swift in Sources */, 887A60792C1D037000E1A461 /* UserInterfaceIdiom.swift in Sources */, + 03C06FCC2D479C7C00600693 /* CarouselComponentViewModel.swift in Sources */, 35D41B432D403556000621C7 /* Store+Localization.swift in Sources */, C3AD12BC2C6EA69D00A4F86F /* SubscriptionDetailsView.swift in Sources */, 887A60742C1D037000E1A461 /* Strings.swift in Sources */, diff --git a/RevenueCatUI/Templates/V2/Components/Carousel/CarouselComponentView.swift b/RevenueCatUI/Templates/V2/Components/Carousel/CarouselComponentView.swift new file mode 100644 index 0000000000..0b7c4687b3 --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/Carousel/CarouselComponentView.swift @@ -0,0 +1,693 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CarouselComponentView.swift +// +// Created by Josh Holtz on 1/27/25. +// swiftlint:disable file_length + +import Foundation +import RevenueCat +import SwiftUI + +#if !os(macOS) && !os(tvOS) // For Paywalls V2 + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct CarouselComponentView: View { + + @EnvironmentObject + private var introOfferEligibilityContext: IntroOfferEligibilityContext + + @EnvironmentObject + private var packageContext: PackageContext + + @Environment(\.componentViewState) + private var componentViewState + + @Environment(\.screenCondition) + private var screenCondition + + let viewModel: CarouselComponentViewModel + let onDismiss: () -> Void + + @State private var carouselHeight: CGFloat = 0 + + var body: some View { + viewModel.styles( + state: self.componentViewState, + condition: self.screenCondition, + isEligibleForIntroOffer: self.introOfferEligibilityContext.isEligible( + package: self.packageContext.package + ) + ) { style in + if style.visible { + GeometryReader { reader in + CarouselView( + width: reader.size.width, + pages: self.viewModel.pageStackViewModels.map({ stackViewModel in + StackComponentView( + viewModel: stackViewModel, + onDismiss: self.onDismiss + ) + }), + initialIndex: style.initialPageIndex, + loop: style.loop, + spacing: style.pageSpacing, + cardWidth: reader.size.width - (style.pagePeek * 2) - style.pageSpacing, + pageControl: style.pageControl, + msTimePerSlide: style.autoAdvance?.msTimePerPage, + msTransitionTime: style.autoAdvance?.msTransitionTime + ).clipped() + } + // Need to set height since geometry reader has no intrinsic height + .frame(height: carouselHeight) + .onPreferenceChange(HeightPreferenceKey.self) { newHeight in + self.carouselHeight = newHeight + } + // Style the carousel + .size(style.size) + .padding(style.padding) + .shape(border: style.border, + shape: style.shape, + background: style.backgroundStyle, + uiConfigProvider: self.viewModel.uiConfigProvider) + .shadow(shadow: style.shadow, shape: style.shape?.toInsettableShape()) + .padding(style.margin) + } + } + } + +} + +struct HeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +/// A wrapper to give each page copy a stable, unique identity. +private struct CarouselItem: Identifiable { + let id: Int + let view: Content +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private struct CarouselView: View { + // MARK: - Configuration + + private let width: CGFloat + private let initialIndex: Int + private let originalPages: [Content] + private let loop: Bool + private let spacing: CGFloat + private let cardWidth: CGFloat + + private let pageControl: DisplayablePageControl? + + /// Optional auto-play timings (in milliseconds). + private let msTimePerSlide: Int? + private let msTransitionTime: Int? + + // Number of pages in the user's original set. + private var originalCount: Int { originalPages.count } + + // MARK: - State + + /// The “expanded” data array, each item with a unique ID. + @State private var data: [CarouselItem] = [] + + /// The current index (in `data`) of the "active" page. + @State private var index: Int = 0 + + /// Real-time drag offset from the user’s finger. + @GestureState private var translation: CGFloat = 0 + + /// A timer for auto-play, if enabled. + @State private var autoTimer: Timer? + @State private var isPaused: Bool = false + @State private var pauseEndDate: Date? + + // MARK: - Init + + init( + width: CGFloat, + pages: [Content], + initialIndex: Int, + loop: Bool, + spacing: CGFloat, + cardWidth: CGFloat, + pageControl: DisplayablePageControl?, + /// If either of these is nil, auto‐play is off. + msTimePerSlide: Int?, + msTransitionTime: Int? + ) { + self.width = width + self.initialIndex = initialIndex + self.originalPages = pages + self.loop = loop + self.spacing = spacing + self.cardWidth = cardWidth + self.pageControl = pageControl + self.msTimePerSlide = msTimePerSlide + self.msTransitionTime = msTransitionTime + } + + // MARK: - Body + + var body: some View { + VStack { + // If top page control + if let pageControl = self.pageControl, pageControl.position == .top { + PageControlView( + originalCount: self.originalCount, + pageControl: pageControl, + currentIndex: self.$index + ) + } + + // Main horizontal “strip” of pages: + HStack(spacing: spacing) { + ForEach(data) { item in + item.view + .frame(width: cardWidth) + } + } + .frame(width: self.width, alignment: .leading) + .offset(x: xOffset(in: self.width)) + // Animate only final snaps (or auto transitions), not real-time dragging + .animation(.spring(), value: index) + .gesture( + DragGesture() + .onChanged({ _ in + pauseAutoPlay(for: 10) + }) + .updating($translation) { value, state, _ in + state = value.translation.width + } + .onEnded { value in + handleDragEnd(translation: value.translation.width) + } + ) + .simultaneousGesture( + TapGesture() + .onEnded { + pauseAutoPlay(for: 10) // Pause on any tap interaction + } + ) + + // If bottom page control + if let pageControl = self.pageControl, pageControl.position == .bottom { + PageControlView( + originalCount: self.originalCount, + pageControl: pageControl, + currentIndex: self.$index + ) + } + } + .background(GeometryReader { geo in + Color.clear.preference(key: HeightPreferenceKey.self, value: geo.size.height) + }) + .onAppear { + setupData() + startAutoPlayIfNeeded() + } + .onDisappear { + // Stop the timer if view disappears + autoTimer?.invalidate() + autoTimer = nil + } + } + + // MARK: - Setup + + private func setupData() { + guard !originalPages.isEmpty else { return } + + if loop { + // Start with 3 copies so user can swipe freely left or right. + let firstCopy = makeItems(forCopyIndex: 0) + let secondCopy = makeItems(forCopyIndex: 1) + let thirdCopy = makeItems(forCopyIndex: 2) + + data = firstCopy + secondCopy + thirdCopy + + // Put user in the middle copy + index = originalCount + self.initialIndex + } else { + // Non-looping: just one copy + data = makeItems(forCopyIndex: 0) + index = self.initialIndex + } + } + + /// Create one “copy” of the original pages, each with a unique ID + /// so SwiftUI knows these are distinct items from other copies. + private func makeItems(forCopyIndex copyIndex: Int) -> [CarouselItem] { + originalPages.enumerated().map { (pageIndex, view) in + let uniqueID = copyIndex * originalCount + pageIndex + return CarouselItem(id: uniqueID, view: view) + } + } + + // MARK: - Auto-Play + + private func startAutoPlayIfNeeded() { + guard let msTimePerSlide = msTimePerSlide, + let msTransitionTime = msTransitionTime else { return } + + autoTimer?.invalidate() // Stop any existing timer + + autoTimer = Timer.scheduledTimer(withTimeInterval: Double(msTimePerSlide) / 1000, repeats: true) { _ in + guard !isPaused else { + // If paused, check if 10 seconds have passed + if let pauseEndDate = pauseEndDate, Date() >= pauseEndDate { + isPaused = false // Resume auto-play + } + return + } + + withAnimation(.easeInOut(duration: Double(msTransitionTime) / 1000)) { + index += 1 + if loop { + expandDataIfNeeded() + pruneDataIfNeeded() + } else { + index = min(index, data.count - 1) + } + } + } + } + + // MARK: - Offsets + + private func xOffset(in totalWidth: CGFloat) -> CGFloat { + let itemWidth = cardWidth + spacing + let baseOffset = -CGFloat(index) * itemWidth + let centerAdjustment = (totalWidth - cardWidth) / 2 + return baseOffset + translation + centerAdjustment + } + + // MARK: - Drag Handling + + private func handleDragEnd(translation: CGFloat) { + let threshold = cardWidth / 2 + + if translation < -threshold { + // Swipe left => next + index += 1 + } else if translation > threshold { + // Swipe right => prev + index -= 1 + } + + if loop { + expandDataIfNeeded() + pruneDataIfNeeded() + } else { + // Non-loop clamp + index = max(0, min(index, data.count - 1)) + } + + // Pause auto-play for 10 seconds + pauseAutoPlay(for: 10) + } + + private var autoPlayEnabled: Bool { + return self.msTimePerSlide != nil && self.msTransitionTime != nil + } + + private func pauseAutoPlay(for seconds: TimeInterval) { + guard self.autoPlayEnabled else { return } + + isPaused = true + pauseEndDate = Date().addingTimeInterval(seconds) + + // Restart auto-play after `seconds` seconds + DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { + if Date() >= self.pauseEndDate! { + self.isPaused = false + } + } + } + + // MARK: - Expanding + + private func expandDataIfNeeded() { + // If index is in the first copy, prepend another + if index < originalCount { + let newCopyIndex = lowestCopyIndex() - 1 + let newItems = makeItems(forCopyIndex: newCopyIndex) + data.insert(contentsOf: newItems, at: 0) + index += originalCount // keep user in same “visual” position + } + + // If index is in the last copy, append another + if index >= data.count - originalCount { + let newCopyIndex = highestCopyIndex() + 1 + let newItems = makeItems(forCopyIndex: newCopyIndex) + data.append(contentsOf: newItems) + } + } + + // MARK: - Pruning + + private func pruneDataIfNeeded() { + let copiesInData = data.count / originalCount + let maxCopiesAllowed = 5 + + guard copiesInData > maxCopiesAllowed else { return } + + // If user is at least 2 copies in from the front, we can drop 1 copy from the front. + while index >= 2 * originalCount, + data.count / originalCount > maxCopiesAllowed { + data.removeFirst(originalCount) + index -= originalCount + } + + // If user is at least 2 copies from the end, we can drop 1 copy from the end. + while index < data.count - 2 * originalCount, + data.count / originalCount > maxCopiesAllowed { + data.removeLast(originalCount) + } + } + + // Identify which copy indices we have + private func lowestCopyIndex() -> Int { + guard let firstID = data.first?.id else { return 0 } + return firstID / originalCount + } + + private func highestCopyIndex() -> Int { + guard let lastID = data.last?.id else { return 0 } + return lastID / originalCount + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct PageControlView: View { + + let originalCount: Int + let pageControl: DisplayablePageControl + @Binding var currentIndex: Int + + @State private var localCurrentIndex: Int = 0 + + var activeIndicator: DisplayablePageControlIndicator { + pageControl.active + } + + var indicator: DisplayablePageControlIndicator { + pageControl.default + } + + var body: some View { + if self.originalCount > 1 { + HStack(spacing: self.pageControl.spacing) { + ForEach(0.. 0 else { + self.localCurrentIndex = 0 + return + } + self.localCurrentIndex = newValue % originalCount + } + } + } + } + +} + +#if DEBUG + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct CarouselComponentView_Previews: PreviewProvider { + + // Need to wrap in VStack otherwise preview rerenders and images won't show + static var previews: some View { + // Examples + ScrollView { + CarouselComponentView( + // swiftlint:disable:next force_try + viewModel: try! .init( + component: .init( + pages: [ + .init( + components: [], + size: .init(width: .fill, height: .fixed(120)), + backgroundColor: .init(light: .hex("#FF0000")), + shape: .rectangle(.init(topLeading: 8, + topTrailing: 8, + bottomLeading: 8, + bottomTrailing: 8)) + ), + .init( + components: [], + size: .init(width: .fill, height: .fixed(120)), + backgroundColor: .init(light: .hex("#00FF00")), + shape: .rectangle(.init(topLeading: 8, + topTrailing: 8, + bottomLeading: 8, + bottomTrailing: 8)) + ), + .init( + components: [], + size: .init(width: .fill, height: .fixed(120)), + backgroundColor: .init(light: .hex("#0000FF")), + shape: .rectangle(.init(topLeading: 8, + topTrailing: 8, + bottomLeading: 8, + bottomTrailing: 8)) + ) + ], + pageSpacing: 20, + pagePeek: 40, + initialPageIndex: 1, + loop: false, + pageControl: .init( + position: .bottom, + padding: PaywallComponent.Padding(top: 6, bottom: 6, leading: 20, trailing: 20), + margin: PaywallComponent.Padding(top: 20, bottom: 20, leading: 0, trailing: 0), + backgroundColor: .init(light: .hex("#f0f0f0")), + shape: .pill, + border: nil, + shadow: .init(color: .init(light: .hex("#00000066")), radius: 4, x: 2, y: 2), + spacing: 10, + default: .init( + width: 10, + height: 10, + color: PaywallComponent.ColorScheme(light: .hex("#aeaeae")) + ), + active: .init( + width: 10, + height: 10, + color: PaywallComponent.ColorScheme(light: .hex("#000000")) + ) + ) + ), + localizationProvider: .init( + locale: Locale.current, + localizedStrings: [:] + ) + ), + onDismiss: {} + ) + + CarouselComponentView( + // swiftlint:disable:next force_try + viewModel: try! .init( + component: .init( + padding: PaywallComponent.Padding(top: 20, bottom: 20, leading: 20, trailing: 20), + margin: PaywallComponent.Padding(top: 20, bottom: 20, leading: 20, trailing: 20), + background: .color(.init(light: .hex("#ffcc00"))), + shape: .rectangle(.init(topLeading: 20, + topTrailing: 20, + bottomLeading: 20, + bottomTrailing: 20)), + pages: [ + .init( + components: [], + size: .init(width: .fixed(100), height: .fixed(120)), + backgroundColor: .init(light: .hex("#FF0000")), + shape: .rectangle(.init(topLeading: 8, + topTrailing: 8, + bottomLeading: 8, + bottomTrailing: 8)) + ), + .init( + components: [], + size: .init(width: .fixed(100), height: .fixed(120)), + backgroundColor: .init(light: .hex("#00FF00")), + shape: .rectangle(.init(topLeading: 8, + topTrailing: 8, + bottomLeading: 8, + bottomTrailing: 8)) + ), + .init( + components: [], + size: .init(width: .fixed(100), height: .fixed(120)), + backgroundColor: PaywallComponent.ColorScheme(light: .hex("#0000FF")), + shape: .rectangle(.init(topLeading: 8, + topTrailing: 8, + bottomLeading: 8, + bottomTrailing: 8)) + ) + ], + loop: true, + pageControl: .init( + position: .top, + padding: PaywallComponent.Padding(top: 10, bottom: 10, leading: 16, trailing: 16), + margin: PaywallComponent.Padding(top: 0, bottom: 10, leading: 0, trailing: 0), + backgroundColor: PaywallComponent.ColorScheme(light: .hex("#ffffff")), + shape: .rectangle(.init(topLeading: 8, + topTrailing: 8, + bottomLeading: 8, + bottomTrailing: 8)), + border: .init(color: .init(light: .hex("#cccccc")), width: 1), + shadow: nil, + spacing: 10, + default: .init( + width: 10, + height: 10, + color: PaywallComponent.ColorScheme(light: .hex("#cccccc")) + ), + active: .init( + width: 10, + height: 10, + color: PaywallComponent.ColorScheme(light: .hex("#000000")) + ) + ) + ), + localizationProvider: .init( + locale: Locale.current, + localizedStrings: [:] + ) + ), + onDismiss: {} + ) + + CarouselComponentView( + // swiftlint:disable:next force_try + viewModel: try! .init( + component: .init( + pages: [ + .init( + components: [], + size: .init(width: .fill, height: .fixed(120)), + backgroundColor: .init(light: .hex("#FF0000")), + shape: .rectangle(.init(topLeading: 8, + topTrailing: 8, + bottomLeading: 8, + bottomTrailing: 8)) + ), + .init( + components: [], + size: .init(width: .fill, height: .fixed(120)), + backgroundColor: .init(light: .hex("#00FF00")), + shape: .rectangle(.init(topLeading: 8, + topTrailing: 8, + bottomLeading: 8, + bottomTrailing: 8)) + ), + .init( + components: [], + size: .init(width: .fill, height: .fixed(120)), + backgroundColor: .init(light: .hex("#0000FF")), + shape: .rectangle(.init(topLeading: 8, + topTrailing: 8, + bottomLeading: 8, + bottomTrailing: 8)) + ) + ], + pageSpacing: 20, + pagePeek: 20, + initialPageIndex: 1, + loop: true, + autoAdvance: .init(msTimePerPage: 1000, msTransitionTime: 500), + pageControl: .init( + position: .bottom, + padding: PaywallComponent.Padding(top: 0, bottom: 0, leading: 0, trailing: 0), + margin: PaywallComponent.Padding(top: 10, bottom: 10, leading: 0, trailing: 0), + backgroundColor: nil, + shape: nil, + border: nil, + shadow: nil, + spacing: 10, + default: .init( + width: 10, + height: 10, + color: PaywallComponent.ColorScheme(light: .hex("#4462e96e")) + ), + active: .init( + width: 60, + height: 20, + color: PaywallComponent.ColorScheme(light: .hex("#4462e9")) + ) + ) + ), + localizationProvider: .init( + locale: Locale.current, + localizedStrings: [:] + ) + ), + onDismiss: {} + ) + } + .padding(.vertical) + .previewRequiredEnvironmentProperties() + .previewLayout(.sizeThatFits) + .previewDisplayName("Examples") + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension CarouselComponentViewModel { + + convenience init( + component: PaywallComponent.CarouselComponent, + localizationProvider: LocalizationProvider + ) throws { + let viewModels: [StackComponentViewModel] = try component.pages.map { component in + return try .init( + component: component, + localizationProvider: localizationProvider + ) + } + + try self.init( + localizationProvider: localizationProvider, + uiConfigProvider: .init(uiConfig: PreviewUIConfig.make()), + component: component, + pageStackViewModels: viewModels + ) + } + +} + +#endif + +#endif diff --git a/RevenueCatUI/Templates/V2/Components/Carousel/CarouselComponentViewModel.swift b/RevenueCatUI/Templates/V2/Components/Carousel/CarouselComponentViewModel.swift new file mode 100644 index 0000000000..d5ba7d1182 --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/Carousel/CarouselComponentViewModel.swift @@ -0,0 +1,250 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// CarouselComponentViewModel.swift +// +// Created by Josh Holtz on 1/27/25. + +import Foundation +import RevenueCat +import SwiftUI + +#if !os(macOS) && !os(tvOS) // For Paywalls V2 + +typealias PresentedCarouselPartial = PaywallComponent.PartialCarouselComponent + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +class CarouselComponentViewModel { + + private let localizationProvider: LocalizationProvider + let uiConfigProvider: UIConfigProvider + private let component: PaywallComponent.CarouselComponent + let pageStackViewModels: [StackComponentViewModel] + + private let presentedOverrides: PresentedOverrides? + + init( + localizationProvider: LocalizationProvider, + uiConfigProvider: UIConfigProvider, + component: PaywallComponent.CarouselComponent, + pageStackViewModels: [StackComponentViewModel] + ) throws { + self.localizationProvider = localizationProvider + self.uiConfigProvider = uiConfigProvider + self.component = component + self.pageStackViewModels = pageStackViewModels + + self.presentedOverrides = try self.component.overrides?.toPresentedOverrides { $0 } + } + + @ViewBuilder + func styles( + state: ComponentViewState, + condition: ScreenCondition, + isEligibleForIntroOffer: Bool, + @ViewBuilder apply: @escaping (CarouselComponentStyle) -> some View + ) -> some View { + let partial = PresentedCarouselPartial.buildPartial( + state: state, + condition: condition, + isEligibleForIntroOffer: isEligibleForIntroOffer, + with: self.presentedOverrides + ) + + let style = CarouselComponentStyle( + uiConfigProvider: self.uiConfigProvider, + visible: partial?.visible ?? self.component.visible ?? true, + size: partial?.size ?? self.component.size, + padding: partial?.padding ?? self.component.padding, + margin: partial?.margin ?? self.component.margin, + background: partial?.background ?? self.component.background, + shape: partial?.shape ?? self.component.shape, + border: partial?.border ?? self.component.border, + shadow: partial?.shadow ?? self.component.shadow, + pageAlignment: partial?.pageAlignment ?? self.component.pageAlignment, + pageSpacing: partial?.pageSpacing ?? self.component.pageSpacing, + pagePeek: partial?.pagePeek ?? self.component.pagePeek, + initialPageIndex: partial?.initialPageIndex ?? self.component.initialPageIndex, + loop: partial?.loop ?? self.component.loop, + autoAdvance: partial?.autoAdvance ?? self.component.autoAdvance, + pageControl: partial?.pageControl ?? self.component.pageControl + ) + + apply(style) + } + +} + +extension PresentedCarouselPartial: PresentedPartial { + + static func combine( + _ base: PaywallComponent.PartialCarouselComponent?, + with other: PaywallComponent.PartialCarouselComponent? + ) -> Self { + + let visible = other?.visible ?? base?.visible + let padding = other?.padding ?? base?.padding + let margin = other?.margin ?? base?.margin + let background = other?.background ?? base?.background + let shape = other?.shape ?? base?.shape + let border = other?.border ?? base?.border + let shadow = other?.shadow ?? base?.shadow + + let pageAlignment = other?.pageAlignment ?? base?.pageAlignment + let pageSpacing = other?.pageSpacing ?? base?.pageSpacing + let pagePeek = other?.pagePeek ?? base?.pagePeek + let initialPageIndex = other?.initialPageIndex ?? base?.initialPageIndex + let loop = other?.loop ?? base?.loop + let autoAdvance = other?.autoAdvance ?? base?.autoAdvance + + let pageControl = other?.pageControl ?? base?.pageControl + + return .init( + visible: visible, + padding: padding, + margin: margin, + background: background, + shape: shape, + border: border, + shadow: shadow, + pageAlignment: pageAlignment, + pageSpacing: pageSpacing, + pagePeek: pagePeek, + initialPageIndex: initialPageIndex, + loop: loop, + autoAdvance: autoAdvance, + pageControl: pageControl + ) + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct CarouselComponentStyle { + + let visible: Bool + let size: PaywallComponent.Size + let padding: EdgeInsets + let margin: EdgeInsets + let backgroundStyle: BackgroundStyle? + let shape: ShapeModifier.Shape? + let border: ShapeModifier.BorderInfo? + let shadow: ShadowModifier.ShadowInfo? + + let pageAlignment: PaywallComponent.VerticalAlignment + let pageSpacing: CGFloat + let pagePeek: CGFloat + let initialPageIndex: Int + let loop: Bool + let autoAdvance: PaywallComponent.CarouselComponent.AutoAdvanceSlides? + + let pageControl: DisplayablePageControl? + + init( + uiConfigProvider: UIConfigProvider, + visible: Bool, + size: PaywallComponent.Size?, + padding: PaywallComponent.Padding?, + margin: PaywallComponent.Padding?, + background: PaywallComponent.Background?, + shape: PaywallComponent.Shape?, + border: PaywallComponent.Border?, + shadow: PaywallComponent.Shadow?, + pageAlignment: PaywallComponent.VerticalAlignment, + pageSpacing: Int, + pagePeek: Int, + initialPageIndex: Int, + loop: Bool, + autoAdvance: PaywallComponent.CarouselComponent.AutoAdvanceSlides?, + pageControl: PaywallComponent.CarouselComponent.PageControl? + ) { + self.visible = visible + self.size = size ?? .init(width: .fit, height: .fit) + self.padding = (padding ?? .zero).edgeInsets + self.margin = (margin ?? .zero).edgeInsets + self.backgroundStyle = background?.asDisplayable(uiConfigProvider: uiConfigProvider).backgroundStyle + self.shape = shape?.shape + self.border = border?.border(uiConfigProvider: uiConfigProvider) + self.shadow = shadow?.shadow(uiConfigProvider: uiConfigProvider) + self.pageAlignment = pageAlignment + self.pageSpacing = CGFloat(pageSpacing) + self.pagePeek = CGFloat(pagePeek) + self.initialPageIndex = initialPageIndex + self.loop = loop + self.autoAdvance = autoAdvance + self.pageControl = pageControl.flatMap { + DisplayablePageControl(uiConfigProvider: uiConfigProvider, pageControl: $0 ) + } + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct DisplayablePageControl { + + let position: PaywallComponent.CarouselComponent.PageControl.Position + let padding: EdgeInsets + let margin: EdgeInsets + let backgroundStyle: BackgroundStyle? + let shape: ShapeModifier.Shape? + let border: ShapeModifier.BorderInfo? + let shadow: ShadowModifier.ShadowInfo? + + let spacing: CGFloat + let active: DisplayablePageControlIndicator + let `default`: DisplayablePageControlIndicator + + let uiConfigProvider: UIConfigProvider + + init( + uiConfigProvider: UIConfigProvider, + pageControl: PaywallComponent.CarouselComponent.PageControl + ) { + self.position = pageControl.position + self.padding = (pageControl.padding ?? .zero).edgeInsets + self.margin = (pageControl.margin ?? .zero).edgeInsets + self.backgroundStyle = pageControl.backgroundColor?.asDisplayable( + uiConfigProvider: uiConfigProvider + ).backgroundStyle + self.shape = pageControl.shape?.shape + self.border = pageControl.border?.border(uiConfigProvider: uiConfigProvider) + self.shadow = pageControl.shadow?.shadow(uiConfigProvider: uiConfigProvider) + + self.spacing = CGFloat(pageControl.spacing) + self.active = .init(uiConfigProvider: uiConfigProvider, pageControlIndicator: pageControl.active) + self.default = .init(uiConfigProvider: uiConfigProvider, pageControlIndicator: pageControl.default) + + self.uiConfigProvider = uiConfigProvider + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct DisplayablePageControlIndicator { + + let width: CGFloat + let height: CGFloat + let color: Color + + let uiConfigProvider: UIConfigProvider + + init( + uiConfigProvider: UIConfigProvider, + pageControlIndicator: PaywallComponent.CarouselComponent.PageControlIndicator + ) { + self.width = CGFloat(pageControlIndicator.width) + self.height = CGFloat(pageControlIndicator.height) + self.color = pageControlIndicator.color.asDisplayable(uiConfigProvider: uiConfigProvider).toDynamicColor() + + self.uiConfigProvider = uiConfigProvider + } + +} + +#endif diff --git a/RevenueCatUI/Templates/V2/Components/ComponentsView.swift b/RevenueCatUI/Templates/V2/Components/ComponentsView.swift index 44bac1453d..8237fde632 100644 --- a/RevenueCatUI/Templates/V2/Components/ComponentsView.swift +++ b/RevenueCatUI/Templates/V2/Components/ComponentsView.swift @@ -74,6 +74,8 @@ struct ComponentsView: View { TabControlButtonComponentView(viewModel: viewModel, onDismiss: onDismiss) case .tabControlToggle(let viewModel): TabControlToggleComponentView(viewModel: viewModel, onDismiss: onDismiss) + case .carousel(let viewModel): + CarouselComponentView(viewModel: viewModel, onDismiss: onDismiss) } } // Applies a top padding to mimmic safe area insets diff --git a/RevenueCatUI/Templates/V2/ViewModelHelpers/PaywallComponentViewModel.swift b/RevenueCatUI/Templates/V2/ViewModelHelpers/PaywallComponentViewModel.swift index 21508a6276..77eec1b5c9 100644 --- a/RevenueCatUI/Templates/V2/ViewModelHelpers/PaywallComponentViewModel.swift +++ b/RevenueCatUI/Templates/V2/ViewModelHelpers/PaywallComponentViewModel.swift @@ -36,6 +36,8 @@ enum PaywallComponentViewModel { case tabControlButton(TabControlButtonComponentViewModel) case tabControlToggle(TabControlToggleComponentViewModel) + case carousel(CarouselComponentViewModel) + } #endif diff --git a/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift b/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift index 421e1148d3..8cd6510177 100644 --- a/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift +++ b/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift @@ -10,6 +10,7 @@ // ViewModelFactory.swift // // Created by Josh Holtz on 11/5/24. +// swiftlint:disable file_length import Foundation import RevenueCat @@ -283,6 +284,26 @@ struct ViewModelFactory { uiConfigProvider: uiConfigProvider ) ) + case .carousel(let component): + let pageStackViewModels = try component.pages.map { stackComponent in + try toStackViewModel( + component: stackComponent, + packageValidator: packageValidator, + firstImageInfo: firstImageInfo, + localizationProvider: localizationProvider, + uiConfigProvider: uiConfigProvider, + offering: offering + ) + } + + return .carousel( + try CarouselComponentViewModel( + localizationProvider: localizationProvider, + uiConfigProvider: uiConfigProvider, + component: component, + pageStackViewModels: pageStackViewModels + ) + ) } } @@ -389,6 +410,11 @@ struct ViewModelFactory { return nil case .tabControlToggle: return nil + case .carousel(let carousel): + guard let first = carousel.pages.first?.components.first else { + return nil + } + return self.findFullWidthImageViewIfItsTheFirst(first) } } diff --git a/Sources/Networking/Responses/RevenueCatUI/UIConfig.swift b/Sources/Networking/Responses/RevenueCatUI/UIConfig.swift index 1b78bc7467..4ab55400ff 100644 --- a/Sources/Networking/Responses/RevenueCatUI/UIConfig.swift +++ b/Sources/Networking/Responses/RevenueCatUI/UIConfig.swift @@ -85,7 +85,7 @@ public struct UIConfig: Codable, Equatable, Sendable { private enum FontInfoTypes: String, Decodable { case name - case googleFonts + case googleFonts = "google_fonts" } diff --git a/Sources/Paywalls/Components/Common/PaywallComponentBase.swift b/Sources/Paywalls/Components/Common/PaywallComponentBase.swift index 2176303fc2..cb4a07af04 100644 --- a/Sources/Paywalls/Components/Common/PaywallComponentBase.swift +++ b/Sources/Paywalls/Components/Common/PaywallComponentBase.swift @@ -26,6 +26,8 @@ public enum PaywallComponent: Codable, Sendable, Hashable, Equatable { case tabControlButton(TabControlButtonComponent) case tabControlToggle(TabControlToggleComponent) + case carousel(CarouselComponent) + public enum ComponentType: String, Codable, Sendable { case text @@ -43,6 +45,8 @@ public enum PaywallComponent: Codable, Sendable, Hashable, Equatable { case tabControlButton = "tab_control_button" case tabControlToggle = "tab_control_toggle" + case carousel + } } @@ -107,6 +111,9 @@ extension PaywallComponent { case .tabControlToggle(let component): try container.encode(ComponentType.tabControlToggle, forKey: .type) try component.encode(to: encoder) + case .carousel(let component): + try container.encode(ComponentType.carousel, forKey: .type) + try component.encode(to: encoder) } } @@ -186,6 +193,8 @@ extension PaywallComponent { return .tabControlButton(try TabControlButtonComponent(from: decoder)) case .tabControlToggle: return .tabControlToggle(try TabControlToggleComponent(from: decoder)) + case .carousel: + return .carousel(try CarouselComponent(from: decoder)) } } diff --git a/Sources/Paywalls/Components/PaywallCarouselComponent.swift b/Sources/Paywalls/Components/PaywallCarouselComponent.swift new file mode 100644 index 0000000000..bc25b98ed9 --- /dev/null +++ b/Sources/Paywalls/Components/PaywallCarouselComponent.swift @@ -0,0 +1,293 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallCarouselComponent.swift +// +// Created by Josh Holtz on 1/26/25. +// swiftlint:disable missing_docs nesting + +import Foundation + +public extension PaywallComponent { + + final class CarouselComponent: PaywallComponentBase { + + public struct AutoAdvanceSlides: PaywallComponentBase { + + public let msTimePerPage: Int + public let msTransitionTime: Int + + public init(msTimePerPage: Int, msTransitionTime: Int) { + self.msTimePerPage = msTimePerPage + self.msTransitionTime = msTransitionTime + } + + } + + public struct PageControl: PaywallComponentBase { + + public enum Position: String, Codable, Sendable, Hashable, Equatable { + case top + case bottom + } + + public let position: Position + public let padding: Padding? + public let margin: Padding? + public let backgroundColor: ColorScheme? + public let shape: Shape? + public let border: Border? + public let shadow: Shadow? + + public let spacing: Int + public let `default`: PageControlIndicator + public let active: PageControlIndicator + + public init( + position: Position, + padding: Padding?, + margin: Padding?, + backgroundColor: ColorScheme?, + shape: Shape?, + border: Border?, + shadow: Shadow?, + spacing: Int, + default: PageControlIndicator, + active: PageControlIndicator + ) { + self.position = position + self.padding = padding + self.margin = margin + self.backgroundColor = backgroundColor + self.shape = shape + self.border = border + self.shadow = shadow + self.spacing = spacing + self.default = `default` + self.active = active + } + + } + + public struct PageControlIndicator: PaywallComponentBase { + + public let width: Int + public let height: Int + public let color: ColorScheme + + public init(width: Int, height: Int, color: ColorScheme) { + self.width = width + self.height = height + self.color = color + } + + } + + let type: ComponentType + + public let visible: Bool? + public let size: Size? + public let padding: Padding? + public let margin: Padding? + public let background: Background? + public let shape: Shape? + public let border: Border? + public let shadow: Shadow? + + public let pages: [StackComponent] + public let pageAlignment: VerticalAlignment + public let pageSpacing: Int + public let pagePeek: Int + public let initialPageIndex: Int + public let loop: Bool + public let autoAdvance: AutoAdvanceSlides? + + public let pageControl: PageControl? + + public let overrides: ComponentOverrides? + + public init( + visible: Bool? = nil, + size: PaywallComponent.Size? = nil, + padding: PaywallComponent.Padding? = .zero, + margin: PaywallComponent.Padding? = .zero, + background: PaywallComponent.Background? = nil, + shape: PaywallComponent.Shape? = nil, + border: PaywallComponent.Border? = nil, + shadow: PaywallComponent.Shadow? = nil, + pages: [PaywallComponent.StackComponent], + pageAlignment: PaywallComponent.VerticalAlignment = .center, + pageSpacing: Int = 0, + pagePeek: Int = 20, + initialPageIndex: Int = 0, + loop: Bool = false, + autoAdvance: PaywallComponent.CarouselComponent.AutoAdvanceSlides? = nil, + pageControl: PageControl? = nil, + overrides: ComponentOverrides? = nil + ) { + self.type = .carousel + + self.visible = visible + self.size = size + self.padding = padding + self.margin = margin + self.background = background + self.shape = shape + self.border = border + self.shadow = shadow + self.pages = pages + self.pageAlignment = pageAlignment + self.pageSpacing = pageSpacing + self.pagePeek = pagePeek + self.initialPageIndex = initialPageIndex + self.loop = loop + self.autoAdvance = autoAdvance + self.pageControl = pageControl + self.overrides = overrides + } + + public static func == (lhs: CarouselComponent, rhs: CarouselComponent) -> Bool { + return lhs.type == rhs.type && + lhs.visible == rhs.visible && + lhs.size == rhs.size && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin && + lhs.background == rhs.background && + lhs.shape == rhs.shape && + lhs.border == rhs.border && + lhs.shadow == rhs.shadow && + lhs.pages == rhs.pages && + lhs.pageAlignment == rhs.pageAlignment && + lhs.pageSpacing == rhs.pageSpacing && + lhs.pagePeek == rhs.pagePeek && + lhs.initialPageIndex == rhs.initialPageIndex && + lhs.loop == rhs.loop && + lhs.autoAdvance == rhs.autoAdvance && + lhs.pageControl == rhs.pageControl && + lhs.overrides == rhs.overrides + } + + // MARK: - Hashable + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(visible) + hasher.combine(size) + hasher.combine(padding) + hasher.combine(margin) + hasher.combine(background) + hasher.combine(shape) + hasher.combine(border) + hasher.combine(shadow) + hasher.combine(pages) + hasher.combine(pageAlignment) + hasher.combine(pageSpacing) + hasher.combine(pagePeek) + hasher.combine(initialPageIndex) + hasher.combine(loop) + hasher.combine(autoAdvance) + hasher.combine(pageControl) + hasher.combine(overrides) + } + + } + + final class PartialCarouselComponent: PaywallPartialComponent { + + public let visible: Bool? + public let size: Size? + public let padding: Padding? + public let margin: Padding? + public let background: Background? + public let shape: Shape? + public let border: Border? + public let shadow: Shadow? + + public let pageAlignment: VerticalAlignment? + public let pageSpacing: Int? + public let pagePeek: Int? + public let initialPageIndex: Int? + public let loop: Bool? + public let autoAdvance: PaywallComponent.CarouselComponent.AutoAdvanceSlides? + + public let pageControl: PaywallComponent.CarouselComponent.PageControl? + + public init( + visible: Bool? = true, + size: PaywallComponent.Size? = nil, + padding: PaywallComponent.Padding? = nil, + margin: PaywallComponent.Padding? = nil, + background: PaywallComponent.Background? = nil, + shape: PaywallComponent.Shape? = nil, + border: PaywallComponent.Border? = nil, + shadow: PaywallComponent.Shadow? = nil, + pageAlignment: PaywallComponent.VerticalAlignment? = nil, + pageSpacing: Int? = nil, + pagePeek: Int? = nil, + initialPageIndex: Int? = nil, + loop: Bool? = nil, + autoAdvance: PaywallComponent.CarouselComponent.AutoAdvanceSlides? = nil, + pageControl: PaywallComponent.CarouselComponent.PageControl? = nil + ) { + self.visible = visible + self.size = size + self.padding = padding + self.margin = margin + self.background = background + self.shape = shape + self.border = border + self.shadow = shadow + self.pageAlignment = pageAlignment + self.pageSpacing = pageSpacing + self.pagePeek = pagePeek + self.initialPageIndex = initialPageIndex + self.loop = loop + self.autoAdvance = autoAdvance + self.pageControl = pageControl + } + + public static func == (lhs: PartialCarouselComponent, rhs: PartialCarouselComponent) -> Bool { + return lhs.visible == rhs.visible && + lhs.size == rhs.size && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin && + lhs.background == rhs.background && + lhs.shape == rhs.shape && + lhs.border == rhs.border && + lhs.shadow == rhs.shadow && + lhs.pageAlignment == rhs.pageAlignment && + lhs.pageSpacing == rhs.pageSpacing && + lhs.pagePeek == rhs.pagePeek && + lhs.initialPageIndex == rhs.initialPageIndex && + lhs.loop == rhs.loop && + lhs.autoAdvance == rhs.autoAdvance && + lhs.pageControl == rhs.pageControl + } + + // MARK: - Hashable + public func hash(into hasher: inout Hasher) { + hasher.combine(visible) + hasher.combine(size) + hasher.combine(padding) + hasher.combine(margin) + hasher.combine(background) + hasher.combine(shape) + hasher.combine(border) + hasher.combine(shadow) + hasher.combine(pageAlignment) + hasher.combine(pageSpacing) + hasher.combine(pagePeek) + hasher.combine(initialPageIndex) + hasher.combine(loop) + hasher.combine(autoAdvance) + hasher.combine(pageControl) + } + + } + +} diff --git a/Sources/Paywalls/Components/PaywallV2CacheWarming.swift b/Sources/Paywalls/Components/PaywallV2CacheWarming.swift index aeb15c767d..9b492cdb72 100644 --- a/Sources/Paywalls/Components/PaywallV2CacheWarming.swift +++ b/Sources/Paywalls/Components/PaywallV2CacheWarming.swift @@ -83,6 +83,10 @@ extension PaywallComponentsData.PaywallComponentsConfig { urls += self.collectAllImageURLs(in: controlButton.stack) case .tabControlToggle: break + case .carousel(let carousel): + urls += carousel.pages.flatMap({ stack in + self.collectAllImageURLs(in: stack) + }) } }