From f10da2464444573e7c6321a79f412c3d485dc085 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Thu, 24 Aug 2023 19:35:06 -0700 Subject: [PATCH] `Paywalls`: iPad polish (#3061) --- RevenueCatUI/Data/Constants.swift | 29 +++++++ RevenueCatUI/Data/UserInterfaceIdiom.swift | 79 +++++++++++++++++++ RevenueCatUI/Helpers/PreviewHelpers.swift | 5 +- RevenueCatUI/Modifiers/ViewExtensions.swift | 47 +++++++++++ RevenueCatUI/Templates/Template1View.swift | 11 ++- RevenueCatUI/Templates/Template2View.swift | 17 ++-- RevenueCatUI/Templates/Template3View.swift | 11 ++- RevenueCatUI/Templates/Template4View.swift | 19 +++-- RevenueCatUI/Templates/TemplateViewType.swift | 1 + RevenueCatUI/Views/FooterView.swift | 19 ++++- RevenueCatUI/Views/PurchaseButton.swift | 4 + .../RevenueCatUITests/BaseSnapshotTest.swift | 1 + .../Templates/Template1ViewTests.swift | 6 ++ .../Templates/Template2ViewTests.swift | 6 ++ .../Templates/Template3ViewTests.swift | 6 ++ .../Templates/Template4ViewTests.swift | 6 ++ .../SnapshotTesting+Extensions.swift | 3 +- 17 files changed, 244 insertions(+), 26 deletions(-) create mode 100644 RevenueCatUI/Data/UserInterfaceIdiom.swift diff --git a/RevenueCatUI/Data/Constants.swift b/RevenueCatUI/Data/Constants.swift index 9dbf8ffc00..31df77eac5 100644 --- a/RevenueCatUI/Data/Constants.swift +++ b/RevenueCatUI/Data/Constants.swift @@ -25,4 +25,33 @@ enum Constants { /// For UI elements that wouldn't make sense to keep scaling up forever static let maximumDynamicTypeSize: DynamicTypeSize = .accessibility3 + static func defaultHorizontalPaddingLength(_ idiom: UserInterfaceIdiom) -> CGFloat? { + if idiom == .pad { + return 24 + } else { + return nil + } + } + + static func defaultVerticalPaddingLength(_ idiom: UserInterfaceIdiom) -> CGFloat? { + if idiom == .pad { + return 16 + } else { + return nil + } + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +extension TemplateViewType { + + var defaultHorizontalPaddingLength: CGFloat? { + return Constants.defaultHorizontalPaddingLength(self.userInterfaceIdiom) + } + + var defaultVerticalPaddingLength: CGFloat? { + return Constants.defaultVerticalPaddingLength(self.userInterfaceIdiom) + } + } diff --git a/RevenueCatUI/Data/UserInterfaceIdiom.swift b/RevenueCatUI/Data/UserInterfaceIdiom.swift new file mode 100644 index 0000000000..d3241c844a --- /dev/null +++ b/RevenueCatUI/Data/UserInterfaceIdiom.swift @@ -0,0 +1,79 @@ +// +// UserInterfaceIdiom.swift +// +// +// Created by Nacho Soto on 8/23/23. +// + +#if canImport(SwiftUI) + +import SwiftUI + +enum UserInterfaceIdiom { + + case phone + case pad + case mac + case unknown + +} + +extension UserInterfaceIdiom { + + #if canImport(UIKit) + static let `default`: Self = UIDevice.interfaceIdiom + #elseif os(macOS) + static let `default`: Self = .mac + #else + static let `default`: Self = .unknown + #endif + +} + +@available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6.2, *) +struct UserInterfaceIdiomEnvironmentKey: EnvironmentKey { + + static var defaultValue: UserInterfaceIdiom = .default + +} + +@available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6.2, *) +extension EnvironmentValues { + + var userInterfaceIdiom: UserInterfaceIdiom { + get { self[UserInterfaceIdiomEnvironmentKey.self] } + set { self[UserInterfaceIdiomEnvironmentKey.self] = newValue } + } + +} + +// MARK: - UIKit + +#if canImport(UIKit) + +private extension UIDevice { + + static var interfaceIdiom: UserInterfaceIdiom { + switch UIDevice.current.userInterfaceIdiom { + case .phone: return .phone + case .pad: return .pad + case .mac: return .mac + + case .tv: return .unknown + case .carPlay: return .unknown + + #if swift(>=5.9) + case .vision: return .unknown + #endif + + case .unspecified: fallthrough + @unknown default: + return .unknown + } + } + +} + +#endif + +#endif diff --git a/RevenueCatUI/Helpers/PreviewHelpers.swift b/RevenueCatUI/Helpers/PreviewHelpers.swift index 1742d1249d..cfa2687ec9 100644 --- a/RevenueCatUI/Helpers/PreviewHelpers.swift +++ b/RevenueCatUI/Helpers/PreviewHelpers.swift @@ -48,6 +48,9 @@ struct PreviewableTemplate: View { typealias Creator = @Sendable @MainActor (TemplateViewConfiguration) -> T + @Environment(\.userInterfaceIdiom) + private var interfaceIdiom + private let configuration: Result private let presentInSheet: Bool private let creator: Creator @@ -79,7 +82,7 @@ struct PreviewableTemplate: View { } var body: some View { - if self.presentInSheet { + if self.presentInSheet || self.interfaceIdiom == .pad { Rectangle() .hidden() .sheet(isPresented: .constant(true)) { diff --git a/RevenueCatUI/Modifiers/ViewExtensions.swift b/RevenueCatUI/Modifiers/ViewExtensions.swift index 3fdb9a58cc..1c6b493ceb 100644 --- a/RevenueCatUI/Modifiers/ViewExtensions.swift +++ b/RevenueCatUI/Modifiers/ViewExtensions.swift @@ -77,6 +77,53 @@ extension View { } } +// MARK: - Padding + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +extension View { + + func defaultHorizontalPadding() -> some View { + return self.modifier(DefaultHorizontalPaddingModifier()) + } + + func defaultVerticalPadding() -> some View { + return self.modifier(DefaultVerticalPaddingModifier()) + } + + func defaultPadding() -> some View { + return self + .defaultHorizontalPadding() + .defaultVerticalPadding() + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +private struct DefaultHorizontalPaddingModifier: ViewModifier { + + @Environment(\.userInterfaceIdiom) + private var interfaceIdiom + + func body(content: Content) -> some View { + content + .padding(.horizontal, Constants.defaultHorizontalPaddingLength(self.interfaceIdiom)) + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, *) +private struct DefaultVerticalPaddingModifier: ViewModifier { + + @Environment(\.userInterfaceIdiom) + private var interfaceIdiom + + func body(content: Content) -> some View { + content + .padding(.vertical, Constants.defaultVerticalPaddingLength(self.interfaceIdiom)) + } + +} + // MARK: - scrollableIfNecessary @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) diff --git a/RevenueCatUI/Templates/Template1View.swift b/RevenueCatUI/Templates/Template1View.swift index 2cc82bb2c3..d95bcf8be0 100644 --- a/RevenueCatUI/Templates/Template1View.swift +++ b/RevenueCatUI/Templates/Template1View.swift @@ -21,6 +21,9 @@ struct Template1View: TemplateViewType { let configuration: TemplateViewConfiguration private var localization: ProcessedLocalizedConfiguration + @Environment(\.userInterfaceIdiom) + var userInterfaceIdiom + @EnvironmentObject private var introEligibilityViewModel: IntroEligibilityViewModel @EnvironmentObject @@ -32,7 +35,7 @@ struct Template1View: TemplateViewType { } var body: some View { - VStack { + VStack(spacing: self.defaultVerticalPaddingLength) { self.scrollableContent .scrollableIfNecessary() .scrollBounceBehaviorBasedOnSize() @@ -47,10 +50,10 @@ struct Template1View: TemplateViewType { ) .font(self.font(for: .callout)) .multilineTextAlignment(.center) - .padding(.horizontal) + .defaultHorizontalPadding() self.button - .padding(.horizontal) + .defaultHorizontalPadding() FooterView(configuration: self.configuration, purchaseHandler: self.purchaseHandler) @@ -59,7 +62,7 @@ struct Template1View: TemplateViewType { @ViewBuilder private var scrollableContent: some View { - VStack { + VStack(spacing: self.defaultVerticalPaddingLength) { if self.configuration.mode.shouldDisplayIcon { self.headerImage } diff --git a/RevenueCatUI/Templates/Template2View.swift b/RevenueCatUI/Templates/Template2View.swift index fb3c27687f..c7a94b08c7 100644 --- a/RevenueCatUI/Templates/Template2View.swift +++ b/RevenueCatUI/Templates/Template2View.swift @@ -26,6 +26,9 @@ struct Template2View: TemplateViewType { @State private var displayingAllPlans: Bool + @Environment(\.userInterfaceIdiom) + var userInterfaceIdiom + @EnvironmentObject private var introEligibilityViewModel: IntroEligibilityViewModel @EnvironmentObject @@ -47,7 +50,7 @@ struct Template2View: TemplateViewType { @ViewBuilder var content: some View { - VStack(spacing: 10) { + VStack(spacing: self.defaultVerticalPaddingLength) { Spacer() self.scrollableContent @@ -58,7 +61,7 @@ struct Template2View: TemplateViewType { } self.subscribeButton - .padding(.horizontal) + .defaultHorizontalPadding() FooterView(configuration: self.configuration, purchaseHandler: self.purchaseHandler, @@ -70,7 +73,7 @@ struct Template2View: TemplateViewType { } private var scrollableContent: some View { - VStack { + VStack(spacing: self.defaultVerticalPaddingLength) { if self.configuration.mode.shouldDisplayIcon { Spacer() self.iconImage @@ -81,14 +84,14 @@ struct Template2View: TemplateViewType { Text(.init(self.selectedLocalization.title)) .foregroundColor(self.configuration.colors.text1Color) .font(self.font(for: .largeTitle).bold()) - .padding(.horizontal) + .defaultHorizontalPadding() Spacer() Text(.init(self.selectedLocalization.subtitle ?? "")) .foregroundColor(self.configuration.colors.text1Color) .font(self.font(for: .title3)) - .padding(.horizontal) + .defaultHorizontalPadding() Spacer() } @@ -119,7 +122,7 @@ struct Template2View: TemplateViewType { .buttonStyle(PackageButtonStyle(isSelected: isSelected)) } } - .padding(.horizontal) + .defaultHorizontalPadding() Spacer() } @@ -132,7 +135,7 @@ struct Template2View: TemplateViewType { self.offerDetails(package: package, selected: selected) } .font(self.font(for: .body).weight(.medium)) - .padding() + .defaultPadding() .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: Self.packageButtonAlignment) .overlay { diff --git a/RevenueCatUI/Templates/Template3View.swift b/RevenueCatUI/Templates/Template3View.swift index c55a0e9e30..4e69f47f72 100644 --- a/RevenueCatUI/Templates/Template3View.swift +++ b/RevenueCatUI/Templates/Template3View.swift @@ -21,6 +21,9 @@ struct Template3View: TemplateViewType { let configuration: TemplateViewConfiguration private let localization: ProcessedLocalizedConfiguration + @Environment(\.userInterfaceIdiom) + var userInterfaceIdiom + @EnvironmentObject private var introEligibilityViewModel: IntroEligibilityViewModel @EnvironmentObject @@ -32,7 +35,7 @@ struct Template3View: TemplateViewType { } var body: some View { - VStack { + VStack(spacing: self.defaultVerticalPaddingLength) { if self.configuration.mode.shouldDisplayIcon { if let url = self.configuration.iconImageURL { RemoteImage(url: url, aspectRatio: 1) @@ -78,8 +81,8 @@ struct Template3View: TemplateViewType { FooterView(configuration: self.configuration, purchaseHandler: self.purchaseHandler) } - .padding(.horizontal) - .padding(.top) + .defaultHorizontalPadding() + .padding(.top, self.defaultVerticalPaddingLength) } private var features: some View { @@ -91,7 +94,7 @@ struct Template3View: TemplateViewType { .accessibilityElement(children: .combine) } } - .padding(.horizontal) + .defaultHorizontalPadding() } private var introEligibility: IntroEligibilityStatus? { diff --git a/RevenueCatUI/Templates/Template4View.swift b/RevenueCatUI/Templates/Template4View.swift index 79bbe8a3ea..1a942ca60a 100644 --- a/RevenueCatUI/Templates/Template4View.swift +++ b/RevenueCatUI/Templates/Template4View.swift @@ -29,6 +29,8 @@ struct Template4View: TemplateViewType { @State private var displayingAllPlans: Bool + @Environment(\.userInterfaceIdiom) + var userInterfaceIdiom @Environment(\.dynamicTypeSize) private var dynamicTypeSize @@ -96,7 +98,7 @@ struct Template4View: TemplateViewType { .dynamicTypeSize(...Constants.maximumDynamicTypeSize) self.subscribeButton - .padding(.horizontal) + .defaultHorizontalPadding() FooterView(configuration: self.configuration, bold: false, @@ -122,7 +124,7 @@ struct Template4View: TemplateViewType { } private var packages: some View { - HStack(spacing: self.packageHorizontalSpacing) { + HStack(spacing: self.totalPackageHorizontalSpacing) { ForEach(self.configuration.packages.all, id: \.content.id) { package in let isSelected = self.selectedPackage.content === package.content @@ -139,7 +141,7 @@ struct Template4View: TemplateViewType { .buttonStyle(PackageButtonStyle(isSelected: isSelected)) } } - .padding(.horizontal, self.packageHorizontalSpacing) + .padding(.horizontal, self.totalPackageHorizontalSpacing) } private var subscribeButton: some View { @@ -175,7 +177,10 @@ struct Template4View: TemplateViewType { private var packageWidth: CGFloat { let packages = self.packagesToDisplay - return self.containerWidth / packages - self.packageHorizontalSpacing * (packages - 1) + return max( + 0, + self.containerWidth / packages - self.totalPackageHorizontalSpacing * (packages - 1) + ) } // MARK: - @@ -190,6 +195,10 @@ struct Template4View: TemplateViewType { @ScaledMetric(relativeTo: .title2) private var packageHorizontalSpacing: CGFloat = 8 + private var totalPackageHorizontalSpacing: CGFloat { + return self.packageHorizontalSpacing + (self.defaultHorizontalPaddingLength ?? 0) + } + private var packagesToDisplay: CGFloat { let desiredCount = { if self.dynamicTypeSize < .xxLarge { @@ -272,7 +281,7 @@ private struct PackageButton: View { .minimumScaleFactor(0.7) } .padding(.vertical, Self.labelVerticalSeparation * 2.0) - .padding(.horizontal) + .defaultHorizontalPadding() .foregroundColor(self.configuration.colors.text1Color) } diff --git a/RevenueCatUI/Templates/TemplateViewType.swift b/RevenueCatUI/Templates/TemplateViewType.swift index b178db7436..9f6fc2167b 100644 --- a/RevenueCatUI/Templates/TemplateViewType.swift +++ b/RevenueCatUI/Templates/TemplateViewType.swift @@ -40,6 +40,7 @@ import SwiftUI protocol TemplateViewType: SwiftUI.View { var configuration: TemplateViewConfiguration { get } + var userInterfaceIdiom: UserInterfaceIdiom { get } init(_ configuration: TemplateViewConfiguration) diff --git a/RevenueCatUI/Views/FooterView.swift b/RevenueCatUI/Views/FooterView.swift index ad9bcbd079..b167c6616d 100644 --- a/RevenueCatUI/Views/FooterView.swift +++ b/RevenueCatUI/Views/FooterView.swift @@ -17,11 +17,14 @@ import SwiftUI @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) struct FooterView: View { + @Environment(\.userInterfaceIdiom) + private var interfaceIdiom + var configuration: PaywallData.Configuration var mode: PaywallViewMode var fonts: PaywallFontProvider var color: Color - var bold: Bool + var boldPreferred: Bool var purchaseHandler: PurchaseHandler var displayingAllPlans: Binding? @@ -54,7 +57,7 @@ struct FooterView: View { self.mode = mode self.fonts = fonts self.color = color - self.bold = bold + self.boldPreferred = bold self.purchaseHandler = purchaseHandler self.displayingAllPlans = displayingAllPlans } @@ -93,7 +96,7 @@ struct FooterView: View { } } .foregroundColor(self.color) - .font(self.fonts.font(for: Self.font).weight(self.fontWeight)) + .font(self.fonts.font(for: self.font).weight(self.fontWeight)) .frame(maxWidth: .infinity) .padding(.horizontal) .padding(.bottom, 5) @@ -114,11 +117,19 @@ struct FooterView: View { SeparatorView(bold: self.bold) } + private var bold: Bool { + return self.boldPreferred && self.interfaceIdiom != .pad + } + private var hasTOS: Bool { self.configuration.termsOfServiceURL != nil } private var hasPrivacy: Bool { self.configuration.privacyURL != nil } private var fontWeight: Font.Weight { self.bold ? .bold : .regular } - private static let font: Font.TextStyle = .caption + fileprivate var font: Font.TextStyle { + return self.interfaceIdiom == .pad + ? .callout + : .caption + } } diff --git a/RevenueCatUI/Views/PurchaseButton.swift b/RevenueCatUI/Views/PurchaseButton.swift index 1541a6f65b..f73c31e5aa 100644 --- a/RevenueCatUI/Views/PurchaseButton.swift +++ b/RevenueCatUI/Views/PurchaseButton.swift @@ -59,6 +59,9 @@ struct PurchaseButton: View { self.purchaseHandler = purchaseHandler } + @Environment(\.userInterfaceIdiom) + var userInterfaceIdiom + var body: some View { self.button } @@ -81,6 +84,7 @@ struct PurchaseButton: View { ? .infinity : nil ) + .padding(.vertical, self.userInterfaceIdiom == .pad ? 10 : 0) } .font(self.fonts.font(for: self.mode.buttonFont).weight(.semibold)) .tint(self.colors.callToActionBackgroundColor) diff --git a/Tests/RevenueCatUITests/BaseSnapshotTest.swift b/Tests/RevenueCatUITests/BaseSnapshotTest.swift index 5d12759514..7fbdf0b35a 100644 --- a/Tests/RevenueCatUITests/BaseSnapshotTest.swift +++ b/Tests/RevenueCatUITests/BaseSnapshotTest.swift @@ -54,6 +54,7 @@ extension BaseSnapshotTest { static let fonts: PaywallFontProvider = CustomPaywallFontProvider(fontName: "Papyrus") static let fullScreenSize: CGSize = .init(width: 460, height: 950) + static let iPadSize: CGSize = .init(width: 744, height: 1130) static let footerSize: CGSize = .init(width: 460, height: 460) } diff --git a/Tests/RevenueCatUITests/Templates/Template1ViewTests.swift b/Tests/RevenueCatUITests/Templates/Template1ViewTests.swift index b64c37d0f2..888f2bc5d9 100644 --- a/Tests/RevenueCatUITests/Templates/Template1ViewTests.swift +++ b/Tests/RevenueCatUITests/Templates/Template1ViewTests.swift @@ -25,6 +25,12 @@ class Template1ViewTests: BaseSnapshotTest { .snapshot(size: Self.fullScreenSize) } + func testTabletPaywall() { + Self.createPaywall(offering: Self.offeringWithNoIntroOffer) + .environment(\.userInterfaceIdiom, .pad) + .snapshot(size: Self.iPadSize) + } + func testCustomFont() { Self.createPaywall(offering: Self.offeringWithNoIntroOffer, fonts: Self.fonts) diff --git a/Tests/RevenueCatUITests/Templates/Template2ViewTests.swift b/Tests/RevenueCatUITests/Templates/Template2ViewTests.swift index 33753d719f..e3b424a220 100644 --- a/Tests/RevenueCatUITests/Templates/Template2ViewTests.swift +++ b/Tests/RevenueCatUITests/Templates/Template2ViewTests.swift @@ -24,6 +24,12 @@ class Template2ViewTests: BaseSnapshotTest { .snapshot(size: Self.fullScreenSize) } + func testTabletPaywall() { + Self.createPaywall(offering: Self.offering.withLocalImages) + .environment(\.userInterfaceIdiom, .pad) + .snapshot(size: Self.iPadSize) + } + func testCustomFont() { Self.createPaywall(offering: Self.offering.withLocalImages, fonts: Self.fonts) diff --git a/Tests/RevenueCatUITests/Templates/Template3ViewTests.swift b/Tests/RevenueCatUITests/Templates/Template3ViewTests.swift index 97becd392e..b06a18cb07 100644 --- a/Tests/RevenueCatUITests/Templates/Template3ViewTests.swift +++ b/Tests/RevenueCatUITests/Templates/Template3ViewTests.swift @@ -24,6 +24,12 @@ class Template3ViewTests: BaseSnapshotTest { .snapshot(size: Self.fullScreenSize) } + func testTabletPaywall() { + Self.createPaywall(offering: Self.offering.withLocalImages) + .environment(\.userInterfaceIdiom, .pad) + .snapshot(size: Self.iPadSize) + } + func testDarkMode() { Self.createPaywall(offering: Self.offering.withLocalImages) .environment(\.colorScheme, .dark) diff --git a/Tests/RevenueCatUITests/Templates/Template4ViewTests.swift b/Tests/RevenueCatUITests/Templates/Template4ViewTests.swift index ea05846585..c5e6200b0b 100644 --- a/Tests/RevenueCatUITests/Templates/Template4ViewTests.swift +++ b/Tests/RevenueCatUITests/Templates/Template4ViewTests.swift @@ -24,6 +24,12 @@ class Template4ViewTests: BaseSnapshotTest { .snapshot(size: Self.fullScreenSize) } + func testTabletPaywall() { + Self.createPaywall(offering: Self.offering.withLocalImages) + .environment(\.userInterfaceIdiom, .pad) + .snapshot(size: Self.iPadSize) + } + func testCustomFont() { Self.createPaywall(offering: Self.offering.withLocalImages, fonts: Self.fonts) diff --git a/Tests/UnitTests/TestHelpers/SnapshotTesting+Extensions.swift b/Tests/UnitTests/TestHelpers/SnapshotTesting+Extensions.swift index dba3b276b7..73832bb92b 100644 --- a/Tests/UnitTests/TestHelpers/SnapshotTesting+Extensions.swift +++ b/Tests/UnitTests/TestHelpers/SnapshotTesting+Extensions.swift @@ -67,7 +67,7 @@ extension SwiftUI.View { controller ).toEventually( haveValidSnapshot( - as: .image(perceptualPrecision: 0.98, size: size, traits: traits), + as: .image(perceptualPrecision: perceptualPrecision, size: size, traits: traits), named: "1", // Force each retry to end in `.1.png` file: file, line: line @@ -84,6 +84,7 @@ private let traits: UITraitCollection = .init(displayScale: 1) #endif +private let perceptualPrecision: Float = 0.97 private let timeout: DispatchTimeInterval = .seconds(5) private let pollInterval: DispatchTimeInterval = .milliseconds(100)