From 7562e75cee144f4058dff91dc9467b8c5aa5085b Mon Sep 17 00:00:00 2001 From: Mark Villacampa Date: Mon, 16 Dec 2024 18:11:51 +0100 Subject: [PATCH 1/6] Add BadgeModifier --- .../Components/Stack/StackComponentView.swift | 4 +- .../Stack/StackComponentViewModel.swift | 55 ++- .../V2/ViewHelpers/BadgeModifier.swift | 430 ++++++++++++++++++ .../Templates/V2/ViewHelpers/Shape.swift | 7 - .../ViewModelHelpers/ViewModelFactory.swift | 6 +- .../PaywallComponentPropertyTypes.swift | 27 +- .../Components/PaywallStackComponent.swift | 8 +- 7 files changed, 521 insertions(+), 16 deletions(-) create mode 100644 RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift diff --git a/RevenueCatUI/Templates/V2/Components/Stack/StackComponentView.swift b/RevenueCatUI/Templates/V2/Components/Stack/StackComponentView.swift index eec364d65c..cee9802ee0 100644 --- a/RevenueCatUI/Templates/V2/Components/Stack/StackComponentView.swift +++ b/RevenueCatUI/Templates/V2/Components/Stack/StackComponentView.swift @@ -99,6 +99,7 @@ struct StackComponentView: View { shadow: style.shadow, background: style.backgroundStyle, uiConfigProvider: self.viewModel.uiConfigProvider) + .badge(style.badge, textComponentViewModel: viewModel.badgeTextViewModel) .padding(style.margin) } @@ -528,7 +529,8 @@ fileprivate extension StackComponentViewModel { try self.init( component: component, viewModels: viewModels, - uiConfigProvider: .init(uiConfig: PreviewUIConfig.make()) + uiConfigProvider: .init(uiConfig: PreviewUIConfig.make()), + localizationProvider: localizationProvider ) } diff --git a/RevenueCatUI/Templates/V2/Components/Stack/StackComponentViewModel.swift b/RevenueCatUI/Templates/V2/Components/Stack/StackComponentViewModel.swift index 9b874ad9d7..dc090078ab 100644 --- a/RevenueCatUI/Templates/V2/Components/Stack/StackComponentViewModel.swift +++ b/RevenueCatUI/Templates/V2/Components/Stack/StackComponentViewModel.swift @@ -24,18 +24,38 @@ class StackComponentViewModel { private let component: PaywallComponent.StackComponent let uiConfigProvider: UIConfigProvider private let presentedOverrides: PresentedOverrides? + let badgeTextViewModel: TextComponentViewModel? let viewModels: [PaywallComponentViewModel] init( component: PaywallComponent.StackComponent, viewModels: [PaywallComponentViewModel], - uiConfigProvider: UIConfigProvider + uiConfigProvider: UIConfigProvider, + localizationProvider: LocalizationProvider ) throws { self.component = component self.viewModels = viewModels self.uiConfigProvider = uiConfigProvider + if let badge = component.badge { + badgeTextViewModel = try TextComponentViewModel( + localizationProvider: localizationProvider, + component: PaywallComponent.TextComponent( + text: badge.textLid, + fontName: badge.fontName, + fontWeight: badge.fontWeight, + color: badge.color, + padding: badge.padding, + margin: .zero, + fontSize: badge.fontSize, + horizontalAlignment: badge.horizontalAlignment + ) + ) + } else { + badgeTextViewModel = nil + } + self.presentedOverrides = try self.component.overrides?.toPresentedOverrides { $0 } } @@ -64,7 +84,8 @@ class StackComponentViewModel { margin: partial?.margin ?? self.component.margin, shape: partial?.shape ?? self.component.shape, border: partial?.border ?? self.component.border, - shadow: partial?.shadow ?? self.component.shadow + shadow: partial?.shadow ?? self.component.shadow, + badge: partial?.badge ?? self.component.badge ) apply(style) @@ -109,6 +130,7 @@ struct StackComponentStyle { let shape: ShapeModifier.Shape? let border: ShapeModifier.BorderInfo? let shadow: ShadowModifier.ShadowInfo? + let badge: BadgeModifier.BadgeInfo? init( uiConfigProvider: UIConfigProvider, @@ -121,7 +143,8 @@ struct StackComponentStyle { margin: PaywallComponent.Padding, shape: PaywallComponent.Shape?, border: PaywallComponent.Border?, - shadow: PaywallComponent.Shadow? + shadow: PaywallComponent.Shadow?, + badge: PaywallComponent.Badge? ) { self.visible = visible self.dimension = dimension @@ -133,6 +156,7 @@ struct StackComponentStyle { self.shape = shape?.shape self.border = border?.border(uiConfigProvider: uiConfigProvider) self.shadow = shadow?.shadow(uiConfigProvider: uiConfigProvider) + self.badge = badge?.badge(parentShape: self.shape) } var vstackStrategy: StackStrategy { @@ -168,7 +192,7 @@ struct StackComponentStyle { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) private extension PaywallComponent.Shape { - var shape: ShapeModifier.Shape? { + var shape: ShapeModifier.Shape { switch self { case .rectangle(let cornerRadiuses): let corners = cornerRadiuses.flatMap { cornerRadiuses in @@ -213,4 +237,27 @@ private extension PaywallComponent.Shadow { } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private extension PaywallComponent.Badge { + + func badge(parentShape: ShapeModifier.Shape?) -> BadgeModifier.BadgeInfo? { + BadgeModifier.BadgeInfo( + style: self.style, + alignment: self.alignment, + shape: self.shape.shape, + padding: self.padding, + margin: self.margin, + textLid: self.textLid, + fontName: self.fontName, + fontWeight: self.fontWeight, + fontSize: self.fontSize, + horizontalAlignment: self.horizontalAlignment, + color: self.color, + backgroundColor: self.backgroundColor, + parentShape: parentShape + ) + } + +} + #endif diff --git a/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift b/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift new file mode 100644 index 0000000000..c1dd1d348f --- /dev/null +++ b/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift @@ -0,0 +1,430 @@ +// +// 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 +// +// BadgeModifier.swift +// +// Created by Mark Villacampa 09/12/2024. + +// swiftlint:disable file_length + +import RevenueCat +import SwiftUI + +#if PAYWALL_COMPONENTS + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct BadgeModifier: ViewModifier { + + let badge: BadgeInfo? + let textComponentViewModel: TextComponentViewModel? + + struct BadgeInfo { + let style: PaywallComponent.BadgeStyle + let alignment: PaywallComponent.TwoDimensionAlignment + let shape: ShapeModifier.Shape + let padding: PaywallComponent.Padding + let margin: PaywallComponent.Padding + let textLid: String + let fontName: String? + let fontWeight: PaywallComponent.FontWeight + let fontSize: PaywallComponent.FontSize + let horizontalAlignment: PaywallComponent.HorizontalAlignment + let color: PaywallComponent.ColorScheme + let backgroundColor: PaywallComponent.ColorScheme + let parentShape: ShapeModifier.Shape? + } + + func body(content: Content) -> some View { + if let badge = badge, let textComponentViewModel = textComponentViewModel { + content.apply(badge: badge, textComponentViewModel: textComponentViewModel) + } else { + content + } + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +fileprivate extension View { + + @ViewBuilder + func text(badge: BadgeModifier.BadgeInfo, textComponentViewModel: TextComponentViewModel) -> some View { + VStack { + TextComponentView(viewModel: textComponentViewModel) + .backgroundStyle(badge.backgroundColor.backgroundStyle) + .shape(border: nil, shape: effectiveShape(badge: badge)) + } + } + + @ViewBuilder + func apply(badge: BadgeModifier.BadgeInfo, textComponentViewModel: TextComponentViewModel) -> some View { + switch badge.style { + case .edgeToEdge: + self.appleBadgeEdgeToEdge(badge: badge, textComponentViewModel: textComponentViewModel) + case .overlaid: + self.overlay( + VStack(alignment: .leading) { + self.text(badge: badge, textComponentViewModel: textComponentViewModel) + .fixedSize() + .padding(effectiveMargin(badge: badge).edgeInsets) + .alignmentGuide( + effetiveVerticalAlinmentForOverlaidBadge(alignment: badge.alignment.stackAlignment), + computeValue: { dim in dim[VerticalAlignment.center] }) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: badge.alignment.stackAlignment) + ) + case .nested: + self.overlay( + VStack(alignment: .leading) { + self.text(badge: badge, textComponentViewModel: textComponentViewModel) + .fixedSize() + .padding(effectiveMargin(badge: badge).edgeInsets) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: badge.alignment.stackAlignment) + ) + } + } + + // Helper to apply the edge-to-edge badge style + @ViewBuilder + private func appleBadgeEdgeToEdge( + badge: BadgeModifier.BadgeInfo, + textComponentViewModel: TextComponentViewModel) -> some View { + switch badge.alignment { + case .bottom: + self.background( + VStack(alignment: .leading) { + self.text(badge: badge, textComponentViewModel: textComponentViewModel) + .alignmentGuide(.bottom) { dim in dim[VerticalAlignment.top] } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: badge.alignment.stackAlignment) + ) + .background( + VStack(alignment: .leading, spacing: 0) { + Rectangle() + .fill(Color.clear) + Rectangle() + .fill(Color.clear) + .backgroundStyle(badge.backgroundColor.backgroundStyle) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + ) + case .top: + self.background( + VStack(alignment: .leading) { + self.text(badge: badge, textComponentViewModel: textComponentViewModel) + .alignmentGuide(.top) { dim in dim[VerticalAlignment.bottom] } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: badge.alignment.stackAlignment) + ) + .background( + VStack(alignment: .leading, spacing: 0) { + Rectangle() + .fill(Color.clear) + .backgroundStyle(badge.backgroundColor.backgroundStyle) + Rectangle() + .fill(Color.clear) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + ) + case .bottomLeading, .bottomTrailing, .topLeading, .topTrailing: + self.overlay( + VStack(alignment: .leading) { + self.text(badge: badge, textComponentViewModel: textComponentViewModel) + .fixedSize() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: badge.alignment.stackAlignment) + ) + default: + self + } + } + + // Helper to calculate the position of an overlaid badge at the top or bottom of the stack + private func effetiveVerticalAlinmentForOverlaidBadge(alignment: Alignment) -> VerticalAlignment { + return switch alignment { + case .top, .topLeading, .topTrailing: + VerticalAlignment.top + case .bottom, .bottomLeading, .bottomTrailing: + VerticalAlignment.bottom + default: + VerticalAlignment.top + } + } + + // Helper to calculate the effective margins of a badge depending on its type: + // - Edge-to-ege: No margin allowed. + // - Overlaid: Only leading/trailing margins allowed if in the leading/trailing positions respectively. + // - Nested: Margin only allowed in the sides adjacent to the stack borders. + // swiftlint:disable:next cyclomatic_complexity + private func effectiveMargin(badge: BadgeModifier.BadgeInfo) -> PaywallComponent.Padding { + switch badge.style { + case .edgeToEdge: + return .zero + case .overlaid: + switch badge.alignment { + case .top, .bottom, .center: + return .zero + case .leading, .topLeading, .bottomLeading: + return .init(top: 0, bottom: 0, leading: badge.margin.leading, trailing: 0) + case .trailing, .topTrailing, .bottomTrailing: + return .init(top: 0, bottom: 0, leading: 0, trailing: badge.margin.trailing) + } + case .nested: + switch badge.alignment { + case .center, .leading, .trailing: + return .zero + case .top: + return .init(top: badge.margin.top, bottom: 0, leading: 0, trailing: 0) + case .bottom: + return .init(top: 0, bottom: badge.margin.bottom, leading: 0, trailing: 0) + case .topLeading: + return .init(top: badge.margin.top, bottom: 0, leading: badge.margin.leading, trailing: 0) + case .topTrailing: + return .init(top: badge.margin.top, bottom: 0, leading: 0, trailing: badge.margin.trailing) + case .bottomLeading: + return .init(top: 0, bottom: badge.margin.bottom, leading: badge.margin.leading, trailing: 0) + case .bottomTrailing: + return .init(top: 0, bottom: badge.margin.bottom, leading: 0, trailing: badge.margin.trailing) + } + } + } + + // Helper to calculate the shape of the edge-to-edge badge in trailing/leading positions. + // swiftlint:disable:next cyclomatic_complexity + private func effectiveShape(badge: BadgeModifier.BadgeInfo) -> ShapeModifier.Shape? { + switch badge.style { + case .edgeToEdge: + switch badge.shape { + case .pill, .concave, .convex: + // Edge-to-edge badge cannot have pill shape + return nil + case .rectangle(let corners): + switch badge.alignment { + case .center, .leading, .trailing: + return nil + case .top: + return .rectangle(.init( + topLeft: corners?.topLeft, + topRight: corners?.topRight, + bottomLeft: 0, + bottomRight: 0)) + case .bottom: + return .rectangle(.init( + topLeft: 0, + topRight: 0, + bottomLeft: corners?.bottomLeft, + bottomRight: corners?.bottomRight)) + case .topLeading: + return .rectangle(.init( + topLeft: radiusInfo(shape: badge.parentShape)?.topLeft, + topRight: 0, + bottomLeft: 0, + bottomRight: corners?.bottomRight)) + case .topTrailing: + return .rectangle(.init( + topLeft: 0.0, + topRight: radiusInfo(shape: badge.parentShape)?.topRight, + bottomLeft: corners?.bottomLeft, + bottomRight: 0)) + case .bottomLeading: + return .rectangle(.init( + topLeft: 0.0, + topRight: corners?.topRight, + bottomLeft: radiusInfo(shape: badge.parentShape)?.bottomLeft, + bottomRight: 0)) + case .bottomTrailing: + return .rectangle(.init( + topLeft: corners?.topLeft, + topRight: 0, + bottomLeft: 0, + bottomRight: radiusInfo(shape: badge.parentShape)?.bottomRight)) + } + } + case .nested, .overlaid: + return badge.shape + } + } + + // Helper to extract the RadiusInfo from a rectable shape + private func radiusInfo(shape: ShapeModifier.Shape?) -> ShapeModifier.RadiusInfo? { + switch shape { + case .rectangle(let radius): + return radius + default: + return nil + } + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension View { + func badge(_ badge: BadgeModifier.BadgeInfo?, textComponentViewModel: TextComponentViewModel?) -> some View { + self.modifier(BadgeModifier(badge: badge, textComponentViewModel: textComponentViewModel)) + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@ViewBuilder +// swiftlint:disable:next function_body_length +private func badge(style: PaywallComponent.BadgeStyle, alignment: PaywallComponent.TwoDimensionAlignment) -> some View { + VStack(spacing: 16) { + Text("Standard") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.black) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Feature 1") + .foregroundColor(.black) + } + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Feature 2") + .foregroundColor(.black) + } + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Feature 3") + .foregroundColor(.black) + } + } + + Text("$9.99/month") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.black) + + Text("Includes 7 Day Free Trial") + .font(.caption) + .foregroundColor(.gray) + + Text("Continue") + .fontWeight(.bold) + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(12) + + } + .padding() + .padding(.vertical, 34) + .backgroundStyle(.color(.init(light: .hex("#ffffff")))) + .shape( + border: .init(color: .blue, width: 10), + shape: .rectangle(ShapeModifier.RadiusInfo(topLeft: 12.0, topRight: 12, bottomLeft: 12, bottomRight: 12)) + ) + .compositingGroup() + .shadow(color: Color.black.opacity(0.5), radius: 4, x: 0, y: 4) + .badge( + BadgeModifier.BadgeInfo( + style: style, + alignment: alignment, + shape: .rectangle(.init(topLeft: 8.0, topRight: 8, bottomLeft: 8, bottomRight: 8)), + padding: .init(top: 4, bottom: 4, leading: 16, trailing: 16), + margin: .init(top: 10, bottom: 10, leading: 10, trailing: 10), + textLid: "id_1", + fontName: nil, + fontWeight: .bold, + fontSize: .bodyS, + horizontalAlignment: .center, + color: .init(light: .hex("#000000")), + backgroundColor: .init(light: .hex("#FA8072")), + parentShape: .rectangle(.init(topLeft: 12.0, topRight: 12, bottomLeft: 12, bottomRight: 12)) + ), + // swiftlint:disable:next force_try + textComponentViewModel: try! TextComponentViewModel( + localizationProvider: .init( + locale: Locale.current, + localizedStrings: [ + "id_1": .string("Special Discount\nSave 50%") + ] + ), + component: PaywallComponent.TextComponent( + text: "id_1", + fontName: nil, + fontWeight: .bold, + color: .init(light: .hex("#000000")), + padding: .init(top: 4, bottom: 4, leading: 16, trailing: 16), + margin: .zero, + fontSize: .bodyS, + horizontalAlignment: .center + ) + ) + + ) +} + +// As of Xcode 16, there is a limit of 15 views per PreviewProvider. +// To work around this, we can create multiple PreviewProviders with different sets of previews. + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct BadgeEdgeToEdge_Previews: PreviewProvider { + + static var previews: some View { + let alignments: [PaywallComponent.TwoDimensionAlignment] = [ + .topLeading, .top, .topTrailing, .bottomLeading, .bottom, .bottomTrailing + ] + ForEach(alignments, id: \.self) { alignment in + badge(style: .edgeToEdge, alignment: alignment) + .previewDisplayName("edgeToEdge - \(alignment)") + } + .previewLayout(.sizeThatFits) + .padding(30) + .padding(.vertical, 50) + .previewRequiredEnvironmentProperties() + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct BadgeOverlaid_Previews: PreviewProvider { + + static var previews: some View { + let alignments: [PaywallComponent.TwoDimensionAlignment] = [ + .topLeading, .top, .topTrailing, .bottomLeading, .bottom, .bottomTrailing + ] + ForEach(alignments, id: \.self) { alignment in + badge(style: .overlaid, alignment: alignment) + .previewDisplayName("overlaid - \(alignment)") + } + .previewLayout(.sizeThatFits) + .padding(30) + .padding(.vertical, 50) + .previewRequiredEnvironmentProperties() + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct BadgeNested_Previews: PreviewProvider { + + static var previews: some View { + let alignments: [PaywallComponent.TwoDimensionAlignment] = [ + .topLeading, .top, .topTrailing, .bottomLeading, .bottom, .bottomTrailing + ] + ForEach(alignments, id: \.self) { alignment in + badge(style: .nested, alignment: alignment) + .previewDisplayName("nested - \(alignment)") + } + .previewLayout(.sizeThatFits) + .padding(30) + .padding(.vertical, 50) + .previewRequiredEnvironmentProperties() + } + +} + +#endif diff --git a/RevenueCatUI/Templates/V2/ViewHelpers/Shape.swift b/RevenueCatUI/Templates/V2/ViewHelpers/Shape.swift index bee22a2e12..48c85e902e 100644 --- a/RevenueCatUI/Templates/V2/ViewHelpers/Shape.swift +++ b/RevenueCatUI/Templates/V2/ViewHelpers/Shape.swift @@ -49,13 +49,6 @@ struct ShapeModifier: ViewModifier { let bottomLeft: CGFloat? let bottomRight: CGFloat? - init(topLeft: Double? = nil, topRight: Double? = nil, bottomLeft: Double? = nil, bottomRight: Double? = nil) { - self.topLeft = topLeft.flatMap { CGFloat($0) } - self.topRight = topRight.flatMap { CGFloat($0) } - self.bottomLeft = bottomLeft.flatMap { CGFloat($0) } - self.bottomRight = bottomRight.flatMap { CGFloat($0) } - } - } var border: BorderInfo? diff --git a/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift b/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift index 2988f4ff90..5f1885750d 100644 --- a/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift +++ b/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift @@ -93,7 +93,8 @@ struct ViewModelFactory { return .stack( try StackComponentViewModel(component: component, viewModels: viewModels, - uiConfigProvider: uiConfigProvider) + uiConfigProvider: uiConfigProvider, + localizationProvider: localizationProvider) ) case .button(let component): let stackViewModel = try toStackViewModel( @@ -177,7 +178,8 @@ struct ViewModelFactory { return try StackComponentViewModel( component: component, viewModels: viewModels, - uiConfigProvider: uiConfigProvider + uiConfigProvider: uiConfigProvider, + localizationProvider: localizationProvider ) } diff --git a/Sources/Paywalls/Components/Common/PaywallComponentPropertyTypes.swift b/Sources/Paywalls/Components/Common/PaywallComponentPropertyTypes.swift index 68f70d4384..0c04496f7b 100644 --- a/Sources/Paywalls/Components/Common/PaywallComponentPropertyTypes.swift +++ b/Sources/Paywalls/Components/Common/PaywallComponentPropertyTypes.swift @@ -392,7 +392,7 @@ public extension PaywallComponent { } - enum TwoDimensionAlignment: String, Decodable, Sendable, Hashable, Equatable { + enum TwoDimensionAlignment: String, Codable, Sendable, Hashable, Equatable { case center case leading @@ -461,6 +461,31 @@ public extension PaywallComponent { } + enum BadgeStyle: String, Codable, Sendable, Hashable, Equatable { + + case edgeToEdge = "edge_to_edge" + case overlaid = "overlaid" + case nested = "nested" + + } + + struct Badge: Codable, Sendable, Hashable, Equatable { + + public let style: BadgeStyle + public let alignment: TwoDimensionAlignment + public let shape: Shape + public let padding: Padding + public let margin: Padding + public let textLid: String + public let fontName: String? + public let fontWeight: FontWeight + public let fontSize: FontSize + public let horizontalAlignment: HorizontalAlignment + public let color: ColorScheme + public let backgroundColor: ColorScheme + + } + } #endif diff --git a/Sources/Paywalls/Components/PaywallStackComponent.swift b/Sources/Paywalls/Components/PaywallStackComponent.swift index 3ae3d877f5..226434b119 100644 --- a/Sources/Paywalls/Components/PaywallStackComponent.swift +++ b/Sources/Paywalls/Components/PaywallStackComponent.swift @@ -31,6 +31,7 @@ public extension PaywallComponent { public let shape: Shape? public let border: Border? public let shadow: Shadow? + public let badge: Badge? public let overrides: ComponentOverrides? @@ -45,6 +46,7 @@ public extension PaywallComponent { shape: Shape? = nil, border: Border? = nil, shadow: Shadow? = nil, + badge: Badge? = nil, overrides: ComponentOverrides? = nil ) { self.components = components @@ -58,6 +60,7 @@ public extension PaywallComponent { self.shape = shape self.border = border self.shadow = shadow + self.badge = badge self.overrides = overrides } @@ -75,6 +78,7 @@ public extension PaywallComponent { public let shape: Shape? public let border: Border? public let shadow: Shadow? + public let badge: Badge? public init( visible: Bool? = true, @@ -86,7 +90,8 @@ public extension PaywallComponent { margin: Padding? = nil, shape: Shape? = nil, border: Border? = nil, - shadow: Shadow? = nil + shadow: Shadow? = nil, + badge: Badge? = nil ) { self.visible = visible self.size = size @@ -98,6 +103,7 @@ public extension PaywallComponent { self.shape = shape self.border = border self.shadow = shadow + self.badge = badge } } From 5cd527739d424511840374b95941c30af6a5e1de Mon Sep 17 00:00:00 2001 From: Mark Villacampa Date: Mon, 16 Dec 2024 18:23:09 +0100 Subject: [PATCH 2/6] do not extract Text view --- .../V2/ViewHelpers/BadgeModifier.swift | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift b/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift index c1dd1d348f..4178cbc55e 100644 --- a/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift +++ b/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift @@ -52,15 +52,6 @@ struct BadgeModifier: ViewModifier { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) fileprivate extension View { - @ViewBuilder - func text(badge: BadgeModifier.BadgeInfo, textComponentViewModel: TextComponentViewModel) -> some View { - VStack { - TextComponentView(viewModel: textComponentViewModel) - .backgroundStyle(badge.backgroundColor.backgroundStyle) - .shape(border: nil, shape: effectiveShape(badge: badge)) - } - } - @ViewBuilder func apply(badge: BadgeModifier.BadgeInfo, textComponentViewModel: TextComponentViewModel) -> some View { switch badge.style { @@ -69,7 +60,11 @@ fileprivate extension View { case .overlaid: self.overlay( VStack(alignment: .leading) { - self.text(badge: badge, textComponentViewModel: textComponentViewModel) + VStack { + TextComponentView(viewModel: textComponentViewModel) + .backgroundStyle(badge.backgroundColor.backgroundStyle) + .shape(border: nil, shape: effectiveShape(badge: badge)) + } .fixedSize() .padding(effectiveMargin(badge: badge).edgeInsets) .alignmentGuide( @@ -81,7 +76,11 @@ fileprivate extension View { case .nested: self.overlay( VStack(alignment: .leading) { - self.text(badge: badge, textComponentViewModel: textComponentViewModel) + VStack { + TextComponentView(viewModel: textComponentViewModel) + .backgroundStyle(badge.backgroundColor.backgroundStyle) + .shape(border: nil, shape: effectiveShape(badge: badge)) + } .fixedSize() .padding(effectiveMargin(badge: badge).edgeInsets) } @@ -92,6 +91,7 @@ fileprivate extension View { // Helper to apply the edge-to-edge badge style @ViewBuilder + // swiftlint:disable:next function_body_length private func appleBadgeEdgeToEdge( badge: BadgeModifier.BadgeInfo, textComponentViewModel: TextComponentViewModel) -> some View { @@ -99,7 +99,11 @@ fileprivate extension View { case .bottom: self.background( VStack(alignment: .leading) { - self.text(badge: badge, textComponentViewModel: textComponentViewModel) + VStack { + TextComponentView(viewModel: textComponentViewModel) + .backgroundStyle(badge.backgroundColor.backgroundStyle) + .shape(border: nil, shape: effectiveShape(badge: badge)) + } .alignmentGuide(.bottom) { dim in dim[VerticalAlignment.top] } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: badge.alignment.stackAlignment) @@ -117,7 +121,11 @@ fileprivate extension View { case .top: self.background( VStack(alignment: .leading) { - self.text(badge: badge, textComponentViewModel: textComponentViewModel) + VStack { + TextComponentView(viewModel: textComponentViewModel) + .backgroundStyle(badge.backgroundColor.backgroundStyle) + .shape(border: nil, shape: effectiveShape(badge: badge)) + } .alignmentGuide(.top) { dim in dim[VerticalAlignment.bottom] } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: badge.alignment.stackAlignment) @@ -135,7 +143,11 @@ fileprivate extension View { case .bottomLeading, .bottomTrailing, .topLeading, .topTrailing: self.overlay( VStack(alignment: .leading) { - self.text(badge: badge, textComponentViewModel: textComponentViewModel) + VStack { + TextComponentView(viewModel: textComponentViewModel) + .backgroundStyle(badge.backgroundColor.backgroundStyle) + .shape(border: nil, shape: effectiveShape(badge: badge)) + } .fixedSize() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: badge.alignment.stackAlignment) From 5ab6eadc852e875ea970d0d0bd6a5ddfbd497b28 Mon Sep 17 00:00:00 2001 From: Mark Villacampa Date: Mon, 16 Dec 2024 19:41:12 +0100 Subject: [PATCH 3/6] rename parentShape -> stackShape, typos --- .../Components/Stack/StackComponentViewModel.swift | 8 ++++---- .../Templates/V2/ViewHelpers/BadgeModifier.swift | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/Stack/StackComponentViewModel.swift b/RevenueCatUI/Templates/V2/Components/Stack/StackComponentViewModel.swift index dc090078ab..e077a57ed4 100644 --- a/RevenueCatUI/Templates/V2/Components/Stack/StackComponentViewModel.swift +++ b/RevenueCatUI/Templates/V2/Components/Stack/StackComponentViewModel.swift @@ -24,8 +24,8 @@ class StackComponentViewModel { private let component: PaywallComponent.StackComponent let uiConfigProvider: UIConfigProvider private let presentedOverrides: PresentedOverrides? - let badgeTextViewModel: TextComponentViewModel? + let badgeTextViewModel: TextComponentViewModel? let viewModels: [PaywallComponentViewModel] init( @@ -156,7 +156,7 @@ struct StackComponentStyle { self.shape = shape?.shape self.border = border?.border(uiConfigProvider: uiConfigProvider) self.shadow = shadow?.shadow(uiConfigProvider: uiConfigProvider) - self.badge = badge?.badge(parentShape: self.shape) + self.badge = badge?.badge(stackShape: self.shape) } var vstackStrategy: StackStrategy { @@ -240,7 +240,7 @@ private extension PaywallComponent.Shadow { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) private extension PaywallComponent.Badge { - func badge(parentShape: ShapeModifier.Shape?) -> BadgeModifier.BadgeInfo? { + func badge(stackShape: ShapeModifier.Shape?) -> BadgeModifier.BadgeInfo? { BadgeModifier.BadgeInfo( style: self.style, alignment: self.alignment, @@ -254,7 +254,7 @@ private extension PaywallComponent.Badge { horizontalAlignment: self.horizontalAlignment, color: self.color, backgroundColor: self.backgroundColor, - parentShape: parentShape + stackShape: stackShape ) } diff --git a/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift b/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift index 4178cbc55e..518a1f6688 100644 --- a/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift +++ b/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift @@ -37,7 +37,7 @@ struct BadgeModifier: ViewModifier { let horizontalAlignment: PaywallComponent.HorizontalAlignment let color: PaywallComponent.ColorScheme let backgroundColor: PaywallComponent.ColorScheme - let parentShape: ShapeModifier.Shape? + let stackShape: ShapeModifier.Shape? } func body(content: Content) -> some View { @@ -234,28 +234,28 @@ fileprivate extension View { bottomRight: corners?.bottomRight)) case .topLeading: return .rectangle(.init( - topLeft: radiusInfo(shape: badge.parentShape)?.topLeft, + topLeft: radiusInfo(shape: badge.stackShape)?.topLeft, topRight: 0, bottomLeft: 0, bottomRight: corners?.bottomRight)) case .topTrailing: return .rectangle(.init( topLeft: 0.0, - topRight: radiusInfo(shape: badge.parentShape)?.topRight, + topRight: radiusInfo(shape: badge.stackShape)?.topRight, bottomLeft: corners?.bottomLeft, bottomRight: 0)) case .bottomLeading: return .rectangle(.init( topLeft: 0.0, topRight: corners?.topRight, - bottomLeft: radiusInfo(shape: badge.parentShape)?.bottomLeft, + bottomLeft: radiusInfo(shape: badge.stackShape)?.bottomLeft, bottomRight: 0)) case .bottomTrailing: return .rectangle(.init( topLeft: corners?.topLeft, topRight: 0, bottomLeft: 0, - bottomRight: radiusInfo(shape: badge.parentShape)?.bottomRight)) + bottomRight: radiusInfo(shape: badge.stackShape)?.bottomRight)) } } case .nested, .overlaid: @@ -263,7 +263,7 @@ fileprivate extension View { } } - // Helper to extract the RadiusInfo from a rectable shape + // Helper to extract the RadiusInfo from a rectanle shape private func radiusInfo(shape: ShapeModifier.Shape?) -> ShapeModifier.RadiusInfo? { switch shape { case .rectangle(let radius): @@ -354,7 +354,7 @@ private func badge(style: PaywallComponent.BadgeStyle, alignment: PaywallCompone horizontalAlignment: .center, color: .init(light: .hex("#000000")), backgroundColor: .init(light: .hex("#FA8072")), - parentShape: .rectangle(.init(topLeft: 12.0, topRight: 12, bottomLeft: 12, bottomRight: 12)) + stackShape: .rectangle(.init(topLeft: 12.0, topRight: 12, bottomLeft: 12, bottomRight: 12)) ), // swiftlint:disable:next force_try textComponentViewModel: try! TextComponentViewModel( From dcec6a10bc3d79a21727c34ddd8c596fafa7ef5c Mon Sep 17 00:00:00 2001 From: Mark Villacampa Date: Tue, 17 Dec 2024 16:55:56 +0100 Subject: [PATCH 4/6] update xcodeproj --- RevenueCat.xcodeproj/project.pbxproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index cd0738c03c..9cecc083cf 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -366,6 +366,7 @@ 4DBF1F372B4D572400D52354 /* LocalReceiptFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DBF1F352B4D572400D52354 /* LocalReceiptFetcher.swift */; }; 4DC546272AD44BBE005CDB35 /* EncodedAppleReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC546262AD44BBE005CDB35 /* EncodedAppleReceipt.swift */; }; 4DE3D5742CDB646900838110 /* MockPaywallEventsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FFFE6C52AA9465000B2955C /* MockPaywallEventsManager.swift */; }; + 4DEB9BC52D08CA1700D33E36 /* BadgeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DEB9BC42D08CA1500D33E36 /* BadgeModifier.swift */; }; 4F0201C42A13C85500091612 /* Assertions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0201C32A13C85500091612 /* Assertions.swift */; }; 4F05876F2A5DE03F00E9A834 /* PaywallDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F05876E2A5DE03F00E9A834 /* PaywallDataTests.swift */; }; 4F062D322A85A11600A8A613 /* PaywallData+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F062D312A85A11600A8A613 /* PaywallData+Localization.swift */; }; @@ -1690,6 +1691,7 @@ 4DBC30952B1DFA97001D33C7 /* StoreKitVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitVersion.swift; sourceTree = ""; }; 4DBF1F352B4D572400D52354 /* LocalReceiptFetcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalReceiptFetcher.swift; sourceTree = ""; }; 4DC546262AD44BBE005CDB35 /* EncodedAppleReceipt.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EncodedAppleReceipt.swift; sourceTree = ""; }; + 4DEB9BC42D08CA1500D33E36 /* BadgeModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeModifier.swift; sourceTree = ""; }; 4F0201C32A13C85500091612 /* Assertions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assertions.swift; sourceTree = ""; }; 4F05876E2A5DE03F00E9A834 /* PaywallDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallDataTests.swift; sourceTree = ""; }; 4F062D312A85A11600A8A613 /* PaywallData+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PaywallData+Localization.swift"; sourceTree = ""; }; @@ -2561,6 +2563,7 @@ 2C7457872CEDF7AC004ACE52 /* BackgroundStyle.swift */, 77089F9D2CD39EC100848CD5 /* ShadowModifier.swift */, 4D6F4BCF2CF69DE300353AF6 /* ForegroundColorScheme.swift */, + 4DEB9BC42D08CA1500D33E36 /* BadgeModifier.swift */, 2C91068D2CE2481800189565 /* SizeModifier.swift */, 2CAB87F62CAAB13200247013 /* Shape.swift */, 03C72F8C2D3311D500297FEC /* DisplayableColor.swift */, @@ -6601,6 +6604,7 @@ 3546355F2C391F4D001D7E85 /* PromotionalOfferView.swift in Sources */, 2C7457882CEDF7C0004ACE52 /* BackgroundStyle.swift in Sources */, 2C8EC6DD2CCC7C5B00D6CCF8 /* PackageValidator.swift in Sources */, + 4DEB9BC52D08CA1700D33E36 /* BadgeModifier.swift in Sources */, 778360792CCA85E4000785B8 /* StickyFooterComponentViewModel.swift in Sources */, 2C7457482CEA66AB004ACE52 /* ComponentsView.swift in Sources */, 353756722C382C2800A1B8D6 /* URLUtilities.swift in Sources */, From 1ee7c4ceb21fea55b567fb78612b214f46b3e3d6 Mon Sep 17 00:00:00 2001 From: Mark Villacampa Date: Mon, 13 Jan 2025 17:02:33 +0100 Subject: [PATCH 5/6] update badge to new schema --- .../Components/Stack/StackComponentView.swift | 3 +- .../Stack/StackComponentViewModel.swift | 48 ++-- .../V2/ViewHelpers/BadgeModifier.swift | 220 ++++++++++-------- .../Templates/V2/ViewHelpers/Shape.swift | 8 +- .../ViewModelHelpers/ViewModelFactory.swift | 12 + .../PaywallComponentPropertyTypes.swift | 42 +++- 6 files changed, 188 insertions(+), 145 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/Stack/StackComponentView.swift b/RevenueCatUI/Templates/V2/Components/Stack/StackComponentView.swift index cee9802ee0..66088e347c 100644 --- a/RevenueCatUI/Templates/V2/Components/Stack/StackComponentView.swift +++ b/RevenueCatUI/Templates/V2/Components/Stack/StackComponentView.swift @@ -99,7 +99,7 @@ struct StackComponentView: View { shadow: style.shadow, background: style.backgroundStyle, uiConfigProvider: self.viewModel.uiConfigProvider) - .badge(style.badge, textComponentViewModel: viewModel.badgeTextViewModel) + .stackBadge(style.badge) .padding(style.margin) } @@ -529,6 +529,7 @@ fileprivate extension StackComponentViewModel { try self.init( component: component, viewModels: viewModels, + badgeViewModels: [], uiConfigProvider: .init(uiConfig: PreviewUIConfig.make()), localizationProvider: localizationProvider ) diff --git a/RevenueCatUI/Templates/V2/Components/Stack/StackComponentViewModel.swift b/RevenueCatUI/Templates/V2/Components/Stack/StackComponentViewModel.swift index e077a57ed4..79db01aaaf 100644 --- a/RevenueCatUI/Templates/V2/Components/Stack/StackComponentViewModel.swift +++ b/RevenueCatUI/Templates/V2/Components/Stack/StackComponentViewModel.swift @@ -25,37 +25,20 @@ class StackComponentViewModel { let uiConfigProvider: UIConfigProvider private let presentedOverrides: PresentedOverrides? - let badgeTextViewModel: TextComponentViewModel? let viewModels: [PaywallComponentViewModel] + let badgeViewModels: [PaywallComponentViewModel] init( component: PaywallComponent.StackComponent, viewModels: [PaywallComponentViewModel], + badgeViewModels: [PaywallComponentViewModel], uiConfigProvider: UIConfigProvider, localizationProvider: LocalizationProvider ) throws { self.component = component self.viewModels = viewModels self.uiConfigProvider = uiConfigProvider - - if let badge = component.badge { - badgeTextViewModel = try TextComponentViewModel( - localizationProvider: localizationProvider, - component: PaywallComponent.TextComponent( - text: badge.textLid, - fontName: badge.fontName, - fontWeight: badge.fontWeight, - color: badge.color, - padding: badge.padding, - margin: .zero, - fontSize: badge.fontSize, - horizontalAlignment: badge.horizontalAlignment - ) - ) - } else { - badgeTextViewModel = nil - } - + self.badgeViewModels = badgeViewModels self.presentedOverrides = try self.component.overrides?.toPresentedOverrides { $0 } } @@ -75,6 +58,7 @@ class StackComponentViewModel { let style = StackComponentStyle( uiConfigProvider: self.uiConfigProvider, + badgeViewModels: self.badgeViewModels, visible: partial?.visible ?? true, dimension: partial?.dimension ?? self.component.dimension, size: partial?.size ?? self.component.size, @@ -134,6 +118,7 @@ struct StackComponentStyle { init( uiConfigProvider: UIConfigProvider, + badgeViewModels: [PaywallComponentViewModel], visible: Bool, dimension: PaywallComponent.Dimension, size: PaywallComponent.Size, @@ -156,7 +141,9 @@ struct StackComponentStyle { self.shape = shape?.shape self.border = border?.border(uiConfigProvider: uiConfigProvider) self.shadow = shadow?.shadow(uiConfigProvider: uiConfigProvider) - self.badge = badge?.badge(stackShape: self.shape) + self.badge = badge?.badge(stackShape: self.shape, + badgeViewModels: badgeViewModels, + uiConfigProvider: uiConfigProvider) } var vstackStrategy: StackStrategy { @@ -240,21 +227,16 @@ private extension PaywallComponent.Shadow { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) private extension PaywallComponent.Badge { - func badge(stackShape: ShapeModifier.Shape?) -> BadgeModifier.BadgeInfo? { + func badge(stackShape: ShapeModifier.Shape?, + badgeViewModels: [PaywallComponentViewModel], + uiConfigProvider: UIConfigProvider) -> BadgeModifier.BadgeInfo? { BadgeModifier.BadgeInfo( style: self.style, alignment: self.alignment, - shape: self.shape.shape, - padding: self.padding, - margin: self.margin, - textLid: self.textLid, - fontName: self.fontName, - fontWeight: self.fontWeight, - fontSize: self.fontSize, - horizontalAlignment: self.horizontalAlignment, - color: self.color, - backgroundColor: self.backgroundColor, - stackShape: stackShape + stack: self.stack, + badgeViewModels: badgeViewModels, + stackShape: stackShape, + uiConfigProvider: uiConfigProvider ) } diff --git a/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift b/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift index 518a1f6688..496c50dc67 100644 --- a/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift +++ b/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift @@ -22,27 +22,19 @@ import SwiftUI struct BadgeModifier: ViewModifier { let badge: BadgeInfo? - let textComponentViewModel: TextComponentViewModel? struct BadgeInfo { let style: PaywallComponent.BadgeStyle let alignment: PaywallComponent.TwoDimensionAlignment - let shape: ShapeModifier.Shape - let padding: PaywallComponent.Padding - let margin: PaywallComponent.Padding - let textLid: String - let fontName: String? - let fontWeight: PaywallComponent.FontWeight - let fontSize: PaywallComponent.FontSize - let horizontalAlignment: PaywallComponent.HorizontalAlignment - let color: PaywallComponent.ColorScheme - let backgroundColor: PaywallComponent.ColorScheme + let stack: PaywallComponent.CodableBox + let badgeViewModels: [PaywallComponentViewModel] let stackShape: ShapeModifier.Shape? + let uiConfigProvider: UIConfigProvider? } func body(content: Content) -> some View { - if let badge = badge, let textComponentViewModel = textComponentViewModel { - content.apply(badge: badge, textComponentViewModel: textComponentViewModel) + if let badge = badge { + content.apply(badge: badge) } else { content } @@ -53,23 +45,24 @@ struct BadgeModifier: ViewModifier { fileprivate extension View { @ViewBuilder - func apply(badge: BadgeModifier.BadgeInfo, textComponentViewModel: TextComponentViewModel) -> some View { + func apply(badge: BadgeModifier.BadgeInfo) -> some View { switch badge.style { case .edgeToEdge: - self.appleBadgeEdgeToEdge(badge: badge, textComponentViewModel: textComponentViewModel) + self.applyBadgeEdgeToEdge(badge: badge) case .overlaid: self.overlay( VStack(alignment: .leading) { VStack { - TextComponentView(viewModel: textComponentViewModel) - .backgroundStyle(badge.backgroundColor.backgroundStyle) + ComponentsView(componentViewModels: badge.badgeViewModels, onDismiss: {}) + .backgroundStyle(badge.stack.value.backgroundColor?.backgroundStyle, + uiConfigProvider: badge.uiConfigProvider) .shape(border: nil, shape: effectiveShape(badge: badge)) } - .fixedSize() - .padding(effectiveMargin(badge: badge).edgeInsets) - .alignmentGuide( - effetiveVerticalAlinmentForOverlaidBadge(alignment: badge.alignment.stackAlignment), - computeValue: { dim in dim[VerticalAlignment.center] }) + .fixedSize() + .padding(effectiveMargin(badge: badge).edgeInsets) + .alignmentGuide( + effetiveVerticalAlinmentForOverlaidBadge(alignment: badge.alignment.stackAlignment), + computeValue: { dim in dim[VerticalAlignment.center] }) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: badge.alignment.stackAlignment) ) @@ -77,14 +70,16 @@ fileprivate extension View { self.overlay( VStack(alignment: .leading) { VStack { - TextComponentView(viewModel: textComponentViewModel) - .backgroundStyle(badge.backgroundColor.backgroundStyle) + ComponentsView(componentViewModels: badge.badgeViewModels, onDismiss: {}) + .backgroundStyle(badge.stack.value.backgroundColor?.backgroundStyle, + uiConfigProvider: badge.uiConfigProvider) .shape(border: nil, shape: effectiveShape(badge: badge)) } - .fixedSize() - .padding(effectiveMargin(badge: badge).edgeInsets) + + .fixedSize() + .padding(effectiveMargin(badge: badge).edgeInsets) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: badge.alignment.stackAlignment) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: badge.alignment.stackAlignment) ) } } @@ -92,19 +87,18 @@ fileprivate extension View { // Helper to apply the edge-to-edge badge style @ViewBuilder // swiftlint:disable:next function_body_length - private func appleBadgeEdgeToEdge( - badge: BadgeModifier.BadgeInfo, - textComponentViewModel: TextComponentViewModel) -> some View { + private func applyBadgeEdgeToEdge(badge: BadgeModifier.BadgeInfo) -> some View { switch badge.alignment { case .bottom: self.background( VStack(alignment: .leading) { VStack { - TextComponentView(viewModel: textComponentViewModel) - .backgroundStyle(badge.backgroundColor.backgroundStyle) + ComponentsView(componentViewModels: badge.badgeViewModels, onDismiss: {}) + .backgroundStyle(badge.stack.value.backgroundColor?.backgroundStyle, + uiConfigProvider: badge.uiConfigProvider) .shape(border: nil, shape: effectiveShape(badge: badge)) } - .alignmentGuide(.bottom) { dim in dim[VerticalAlignment.top] } + .alignmentGuide(.bottom) { dim in dim[VerticalAlignment.top] } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: badge.alignment.stackAlignment) ) @@ -114,7 +108,8 @@ fileprivate extension View { .fill(Color.clear) Rectangle() .fill(Color.clear) - .backgroundStyle(badge.backgroundColor.backgroundStyle) + .backgroundStyle(badge.stack.value.backgroundColor?.backgroundStyle, + uiConfigProvider: badge.uiConfigProvider) } .frame(maxWidth: .infinity, maxHeight: .infinity) ) @@ -122,11 +117,12 @@ fileprivate extension View { self.background( VStack(alignment: .leading) { VStack { - TextComponentView(viewModel: textComponentViewModel) - .backgroundStyle(badge.backgroundColor.backgroundStyle) + ComponentsView(componentViewModels: badge.badgeViewModels, onDismiss: {}) + .backgroundStyle(badge.stack.value.backgroundColor?.backgroundStyle, + uiConfigProvider: badge.uiConfigProvider) .shape(border: nil, shape: effectiveShape(badge: badge)) } - .alignmentGuide(.top) { dim in dim[VerticalAlignment.bottom] } + .alignmentGuide(.top) { dim in dim[VerticalAlignment.bottom] } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: badge.alignment.stackAlignment) ) @@ -134,7 +130,8 @@ fileprivate extension View { VStack(alignment: .leading, spacing: 0) { Rectangle() .fill(Color.clear) - .backgroundStyle(badge.backgroundColor.backgroundStyle) + .backgroundStyle(badge.stack.value.backgroundColor?.backgroundStyle, + uiConfigProvider: badge.uiConfigProvider) Rectangle() .fill(Color.clear) } @@ -144,11 +141,12 @@ fileprivate extension View { self.overlay( VStack(alignment: .leading) { VStack { - TextComponentView(viewModel: textComponentViewModel) - .backgroundStyle(badge.backgroundColor.backgroundStyle) + ComponentsView(componentViewModels: badge.badgeViewModels, onDismiss: {}) + .backgroundStyle(badge.stack.value.backgroundColor?.backgroundStyle, + uiConfigProvider: badge.uiConfigProvider) .shape(border: nil, shape: effectiveShape(badge: badge)) } - .fixedSize() + .fixedSize() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: badge.alignment.stackAlignment) ) @@ -183,37 +181,41 @@ fileprivate extension View { case .top, .bottom, .center: return .zero case .leading, .topLeading, .bottomLeading: - return .init(top: 0, bottom: 0, leading: badge.margin.leading, trailing: 0) + return .init(top: 0, bottom: 0, leading: badge.stack.value.margin.leading, trailing: 0) case .trailing, .topTrailing, .bottomTrailing: - return .init(top: 0, bottom: 0, leading: 0, trailing: badge.margin.trailing) + return .init(top: 0, bottom: 0, leading: 0, trailing: badge.stack.value.margin.trailing) } case .nested: switch badge.alignment { case .center, .leading, .trailing: return .zero case .top: - return .init(top: badge.margin.top, bottom: 0, leading: 0, trailing: 0) + return .init(top: badge.stack.value.margin.top, bottom: 0, leading: 0, trailing: 0) case .bottom: - return .init(top: 0, bottom: badge.margin.bottom, leading: 0, trailing: 0) + return .init(top: 0, bottom: badge.stack.value.margin.bottom, leading: 0, trailing: 0) case .topLeading: - return .init(top: badge.margin.top, bottom: 0, leading: badge.margin.leading, trailing: 0) + return .init(top: badge.stack.value.margin.top, bottom: 0, + leading: badge.stack.value.margin.leading, trailing: 0) case .topTrailing: - return .init(top: badge.margin.top, bottom: 0, leading: 0, trailing: badge.margin.trailing) + return .init(top: badge.stack.value.margin.top, bottom: 0, + leading: 0, trailing: badge.stack.value.margin.trailing) case .bottomLeading: - return .init(top: 0, bottom: badge.margin.bottom, leading: badge.margin.leading, trailing: 0) + return .init(top: 0, bottom: badge.stack.value.margin.bottom, + leading: badge.stack.value.margin.leading, trailing: 0) case .bottomTrailing: - return .init(top: 0, bottom: badge.margin.bottom, leading: 0, trailing: badge.margin.trailing) + return .init(top: 0, bottom: badge.stack.value.margin.bottom, + leading: 0, trailing: badge.stack.value.margin.trailing) } } } // Helper to calculate the shape of the edge-to-edge badge in trailing/leading positions. - // swiftlint:disable:next cyclomatic_complexity + // swiftlint:disable:next cyclomatic_complexity function_body_length private func effectiveShape(badge: BadgeModifier.BadgeInfo) -> ShapeModifier.Shape? { switch badge.style { case .edgeToEdge: - switch badge.shape { - case .pill, .concave, .convex: + switch badge.stack.value.shape { + case .pill, .none: // Edge-to-edge badge cannot have pill shape return nil case .rectangle(let corners): @@ -222,44 +224,54 @@ fileprivate extension View { return nil case .top: return .rectangle(.init( - topLeft: corners?.topLeft, - topRight: corners?.topRight, + topLeft: corners?.topLeading, + topRight: corners?.topTrailing, bottomLeft: 0, bottomRight: 0)) case .bottom: return .rectangle(.init( topLeft: 0, topRight: 0, - bottomLeft: corners?.bottomLeft, - bottomRight: corners?.bottomRight)) + bottomLeft: corners?.bottomLeading, + bottomRight: corners?.bottomTrailing)) case .topLeading: return .rectangle(.init( topLeft: radiusInfo(shape: badge.stackShape)?.topLeft, topRight: 0, bottomLeft: 0, - bottomRight: corners?.bottomRight)) + bottomRight: corners?.bottomTrailing)) case .topTrailing: return .rectangle(.init( topLeft: 0.0, topRight: radiusInfo(shape: badge.stackShape)?.topRight, - bottomLeft: corners?.bottomLeft, + bottomLeft: corners?.bottomLeading, bottomRight: 0)) case .bottomLeading: return .rectangle(.init( topLeft: 0.0, - topRight: corners?.topRight, + topRight: corners?.topTrailing, bottomLeft: radiusInfo(shape: badge.stackShape)?.bottomLeft, bottomRight: 0)) case .bottomTrailing: return .rectangle(.init( - topLeft: corners?.topLeft, + topLeft: corners?.topLeading, topRight: 0, bottomLeft: 0, bottomRight: radiusInfo(shape: badge.stackShape)?.bottomRight)) } } case .nested, .overlaid: - return badge.shape + switch badge.stack.value.shape { + case .rectangle(let radius): + return .rectangle(.init(topLeft: radius?.topLeading, + topRight: radius?.topTrailing, + bottomLeft: radius?.bottomLeading, + bottomRight: radius?.bottomTrailing)) + case .pill: + return .pill + case .none: + return nil + } } } @@ -277,8 +289,8 @@ fileprivate extension View { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) extension View { - func badge(_ badge: BadgeModifier.BadgeInfo?, textComponentViewModel: TextComponentViewModel?) -> some View { - self.modifier(BadgeModifier(badge: badge, textComponentViewModel: textComponentViewModel)) + func stackBadge(_ badge: BadgeModifier.BadgeInfo?) -> some View { + self.modifier(BadgeModifier(badge: badge)) } } @@ -333,50 +345,64 @@ private func badge(style: PaywallComponent.BadgeStyle, alignment: PaywallCompone } .padding() .padding(.vertical, 34) - .backgroundStyle(.color(.init(light: .hex("#ffffff")))) + .backgroundStyle(.color(.init(light: .hex("#ffffff"))), uiConfigProvider: .init(uiConfig: PreviewUIConfig.make())) .shape( border: .init(color: .blue, width: 10), shape: .rectangle(ShapeModifier.RadiusInfo(topLeft: 12.0, topRight: 12, bottomLeft: 12, bottomRight: 12)) ) .compositingGroup() .shadow(color: Color.black.opacity(0.5), radius: 4, x: 0, y: 4) - .badge( - BadgeModifier.BadgeInfo( - style: style, - alignment: alignment, - shape: .rectangle(.init(topLeft: 8.0, topRight: 8, bottomLeft: 8, bottomRight: 8)), + .stackBadge( + BadgeModifier.BadgeInfo( + style: style, + alignment: alignment, + stack: PaywallComponent.CodableBox(PaywallComponent.StackComponent( + components: [ + PaywallComponent.text( + PaywallComponent.TextComponent( + text: "id_1", + fontName: nil, + fontWeight: .bold, + color: .init(light: .hex("#000000")), + padding: .init(top: 4, bottom: 4, leading: 16, trailing: 16), + margin: .zero, + fontSize: .bodyS, + horizontalAlignment: .center + ) + ) + ], + backgroundColor: .init(light: .hex("#FA8072")), padding: .init(top: 4, bottom: 4, leading: 16, trailing: 16), margin: .init(top: 10, bottom: 10, leading: 10, trailing: 10), - textLid: "id_1", - fontName: nil, - fontWeight: .bold, - fontSize: .bodyS, - horizontalAlignment: .center, - color: .init(light: .hex("#000000")), - backgroundColor: .init(light: .hex("#FA8072")), - stackShape: .rectangle(.init(topLeft: 12.0, topRight: 12, bottomLeft: 12, bottomRight: 12)) - ), - // swiftlint:disable:next force_try - textComponentViewModel: try! TextComponentViewModel( - localizationProvider: .init( - locale: Locale.current, - localizedStrings: [ - "id_1": .string("Special Discount\nSave 50%") - ] - ), - component: PaywallComponent.TextComponent( - text: "id_1", - fontName: nil, - fontWeight: .bold, - color: .init(light: .hex("#000000")), - padding: .init(top: 4, bottom: 4, leading: 16, trailing: 16), - margin: .zero, - fontSize: .bodyS, - horizontalAlignment: .center + shape: .rectangle(.init(topLeading: 8.0, topTrailing: 8, bottomLeading: 8, bottomTrailing: 8)) + )), badgeViewModels: [ + .text( + // swiftlint:disable:next force_try + try! TextComponentViewModel( + localizationProvider: .init( + locale: Locale.current, + localizedStrings: [ + "id_1": .string("Special Discount\nSave 50%") + ] + ), + uiConfigProvider: .init(uiConfig: PreviewUIConfig.make()), + component: PaywallComponent.TextComponent( + text: "id_1", + fontName: nil, + fontWeight: .bold, + color: .init(light: .hex("#000000")), + padding: .init(top: 4, bottom: 4, leading: 16, trailing: 16), + margin: .zero, + fontSize: .bodyS, + horizontalAlignment: .center + ) + ) ) - ) - + ], + stackShape: .rectangle(.init(topLeft: 12.0, topRight: 12.0, bottomLeft: 12.0, bottomRight: 12.0)), + uiConfigProvider: .init(uiConfig: PreviewUIConfig.make()) ) + ) } // As of Xcode 16, there is a limit of 15 views per PreviewProvider. diff --git a/RevenueCatUI/Templates/V2/ViewHelpers/Shape.swift b/RevenueCatUI/Templates/V2/ViewHelpers/Shape.swift index 48c85e902e..1c8f931d16 100644 --- a/RevenueCatUI/Templates/V2/ViewHelpers/Shape.swift +++ b/RevenueCatUI/Templates/V2/ViewHelpers/Shape.swift @@ -44,10 +44,10 @@ struct ShapeModifier: ViewModifier { struct RadiusInfo: Hashable { - let topLeft: CGFloat? - let topRight: CGFloat? - let bottomLeft: CGFloat? - let bottomRight: CGFloat? + let topLeft: Double? + let topRight: Double? + let bottomLeft: Double? + let bottomRight: Double? } diff --git a/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift b/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift index 5f1885750d..c6ef84259d 100644 --- a/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift +++ b/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift @@ -90,9 +90,20 @@ struct ViewModelFactory { ) } + let badgeViewModels = try component.badge?.stack.value.components.map { component in + try self.toViewModel( + component: component, + packageValidator: packageValidator, + offering: offering, + localizationProvider: localizationProvider, + uiConfigProvider: uiConfigProvider + ) + } + return .stack( try StackComponentViewModel(component: component, viewModels: viewModels, + badgeViewModels: badgeViewModels ?? [], uiConfigProvider: uiConfigProvider, localizationProvider: localizationProvider) ) @@ -178,6 +189,7 @@ struct ViewModelFactory { return try StackComponentViewModel( component: component, viewModels: viewModels, + badgeViewModels: [], uiConfigProvider: uiConfigProvider, localizationProvider: localizationProvider ) diff --git a/Sources/Paywalls/Components/Common/PaywallComponentPropertyTypes.swift b/Sources/Paywalls/Components/Common/PaywallComponentPropertyTypes.swift index 0c04496f7b..4420f0f6c9 100644 --- a/Sources/Paywalls/Components/Common/PaywallComponentPropertyTypes.swift +++ b/Sources/Paywalls/Components/Common/PaywallComponentPropertyTypes.swift @@ -473,19 +473,41 @@ public extension PaywallComponent { public let style: BadgeStyle public let alignment: TwoDimensionAlignment - public let shape: Shape - public let padding: Padding - public let margin: Padding - public let textLid: String - public let fontName: String? - public let fontWeight: FontWeight - public let fontSize: FontSize - public let horizontalAlignment: HorizontalAlignment - public let color: ColorScheme - public let backgroundColor: ColorScheme + public let stack: CodableBox } + // Holds a reference to a `Codable` value. + final class CodableBox: Codable { + + public let value: T + + public init(_ value: T) { self.value = value } + + public required init(from decoder: Decoder) throws { + value = try T(from: decoder) + } + + public func encode(to encoder: Encoder) throws { + try value.encode(to: encoder) + } + + } + +} + +extension PaywallComponent.CodableBox: Sendable where T: Sendable {} + +extension PaywallComponent.CodableBox: Equatable where T: Equatable { + public static func == (lhs: PaywallComponent.CodableBox, rhs: PaywallComponent.CodableBox) -> Bool { + return lhs.value == rhs.value + } +} + +extension PaywallComponent.CodableBox: Hashable where T: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(value) + } } #endif From ce10ded6dcc5ccbe39a0a0a900c2b077d3913a51 Mon Sep 17 00:00:00 2001 From: Mark Villacampa Date: Mon, 13 Jan 2025 17:37:49 +0100 Subject: [PATCH 6/6] fix backgrounds --- .../V2/ViewHelpers/BadgeModifier.swift | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift b/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift index 496c50dc67..1419f0dab0 100644 --- a/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift +++ b/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift @@ -29,7 +29,11 @@ struct BadgeModifier: ViewModifier { let stack: PaywallComponent.CodableBox let badgeViewModels: [PaywallComponentViewModel] let stackShape: ShapeModifier.Shape? - let uiConfigProvider: UIConfigProvider? + let uiConfigProvider: UIConfigProvider + + var backgroundStyle: BackgroundStyle? { + stack.value.backgroundColor?.asDisplayable(uiConfigProvider: uiConfigProvider).backgroundStyle + } } func body(content: Content) -> some View { @@ -54,8 +58,7 @@ fileprivate extension View { VStack(alignment: .leading) { VStack { ComponentsView(componentViewModels: badge.badgeViewModels, onDismiss: {}) - .backgroundStyle(badge.stack.value.backgroundColor?.backgroundStyle, - uiConfigProvider: badge.uiConfigProvider) + .backgroundStyle(badge.backgroundStyle) .shape(border: nil, shape: effectiveShape(badge: badge)) } .fixedSize() @@ -71,8 +74,7 @@ fileprivate extension View { VStack(alignment: .leading) { VStack { ComponentsView(componentViewModels: badge.badgeViewModels, onDismiss: {}) - .backgroundStyle(badge.stack.value.backgroundColor?.backgroundStyle, - uiConfigProvider: badge.uiConfigProvider) + .backgroundStyle(badge.backgroundStyle) .shape(border: nil, shape: effectiveShape(badge: badge)) } @@ -94,8 +96,7 @@ fileprivate extension View { VStack(alignment: .leading) { VStack { ComponentsView(componentViewModels: badge.badgeViewModels, onDismiss: {}) - .backgroundStyle(badge.stack.value.backgroundColor?.backgroundStyle, - uiConfigProvider: badge.uiConfigProvider) + .backgroundStyle(badge.backgroundStyle) .shape(border: nil, shape: effectiveShape(badge: badge)) } .alignmentGuide(.bottom) { dim in dim[VerticalAlignment.top] } @@ -108,8 +109,7 @@ fileprivate extension View { .fill(Color.clear) Rectangle() .fill(Color.clear) - .backgroundStyle(badge.stack.value.backgroundColor?.backgroundStyle, - uiConfigProvider: badge.uiConfigProvider) + .backgroundStyle(badge.backgroundStyle) } .frame(maxWidth: .infinity, maxHeight: .infinity) ) @@ -118,8 +118,7 @@ fileprivate extension View { VStack(alignment: .leading) { VStack { ComponentsView(componentViewModels: badge.badgeViewModels, onDismiss: {}) - .backgroundStyle(badge.stack.value.backgroundColor?.backgroundStyle, - uiConfigProvider: badge.uiConfigProvider) + .backgroundStyle(badge.backgroundStyle) .shape(border: nil, shape: effectiveShape(badge: badge)) } .alignmentGuide(.top) { dim in dim[VerticalAlignment.bottom] } @@ -130,8 +129,7 @@ fileprivate extension View { VStack(alignment: .leading, spacing: 0) { Rectangle() .fill(Color.clear) - .backgroundStyle(badge.stack.value.backgroundColor?.backgroundStyle, - uiConfigProvider: badge.uiConfigProvider) + .backgroundStyle(badge.backgroundStyle) Rectangle() .fill(Color.clear) } @@ -142,8 +140,7 @@ fileprivate extension View { VStack(alignment: .leading) { VStack { ComponentsView(componentViewModels: badge.badgeViewModels, onDismiss: {}) - .backgroundStyle(badge.stack.value.backgroundColor?.backgroundStyle, - uiConfigProvider: badge.uiConfigProvider) + .backgroundStyle(badge.backgroundStyle) .shape(border: nil, shape: effectiveShape(badge: badge)) } .fixedSize() @@ -345,7 +342,7 @@ private func badge(style: PaywallComponent.BadgeStyle, alignment: PaywallCompone } .padding() .padding(.vertical, 34) - .backgroundStyle(.color(.init(light: .hex("#ffffff"))), uiConfigProvider: .init(uiConfig: PreviewUIConfig.make())) + .backgroundStyle(.color(.init(light: .hex("#ffffff"))).backgroundStyle) .shape( border: .init(color: .blue, width: 10), shape: .rectangle(ShapeModifier.RadiusInfo(topLeft: 12.0, topRight: 12, bottomLeft: 12, bottomRight: 12))