Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Paywalls V2] Add purchase button activity indicator #4787

Merged
merged 7 commits into from
Feb 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
030F918A2D55C1D20085103F /* LocaleFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030F91892D55C1AB0085103F /* LocaleFinder.swift */; };
030F918C2D55C9DC0085103F /* LocaleFinderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030F918B2D55C9D80085103F /* LocaleFinderTests.swift */; };
030F918E2D5664410085103F /* LocaleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030F918D2D5664410085103F /* LocaleExtensions.swift */; };
030F93B32D5ED90B0085103F /* ProgressViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030F93B22D5ED90B0085103F /* ProgressViewModifier.swift */; };
0313FD41268A506400168386 /* DateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313FD40268A506400168386 /* DateProvider.swift */; };
0354AA462D4029C300F9E330 /* TabControlButtonComponentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0354AA3E2D4029C300F9E330 /* TabControlButtonComponentViewModel.swift */; };
0354AA472D4029C300F9E330 /* TabControlComponentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0354AA402D4029C300F9E330 /* TabControlComponentViewModel.swift */; };
Expand Down Expand Up @@ -1295,6 +1296,7 @@
030F91892D55C1AB0085103F /* LocaleFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocaleFinder.swift; sourceTree = "<group>"; };
030F918B2D55C9D80085103F /* LocaleFinderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocaleFinderTests.swift; sourceTree = "<group>"; };
030F918D2D5664410085103F /* LocaleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocaleExtensions.swift; sourceTree = "<group>"; };
030F93B22D5ED90B0085103F /* ProgressViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressViewModifier.swift; sourceTree = "<group>"; };
0313FD40268A506400168386 /* DateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateProvider.swift; sourceTree = "<group>"; };
0354AA3D2D4029C300F9E330 /* TabControlButtonComponentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabControlButtonComponentView.swift; sourceTree = "<group>"; };
0354AA3E2D4029C300F9E330 /* TabControlButtonComponentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabControlButtonComponentViewModel.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2687,6 +2689,7 @@
2C7457442CEA652B004ACE52 /* ViewHelpers */ = {
isa = PBXGroup;
children = (
030F93B22D5ED90B0085103F /* ProgressViewModifier.swift */,
2C7457872CEDF7AC004ACE52 /* BackgroundStyle.swift */,
77089F9D2CD39EC100848CD5 /* ShadowModifier.swift */,
4D6F4BCF2CF69DE300353AF6 /* ForegroundColorScheme.swift */,
Expand Down Expand Up @@ -6900,6 +6903,7 @@
357CEC702C5940CE00A80837 /* ColorFromAppearance.swift in Sources */,
887A60832C1D037000E1A461 /* VersionDetector.swift in Sources */,
574D1C702D3E75F9005840CD /* PurchaseDetailView.swift in Sources */,
030F93B32D5ED90B0085103F /* ProgressViewModifier.swift in Sources */,
574D1C712D3E75F9005840CD /* PurchaseHistoryView.swift in Sources */,
574D1C722D3E75F9005840CD /* PurchaseLinkView.swift in Sources */,
88B1BAFC2C813A3C001B7EE5 /* ImageComponentView.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,13 @@ struct PurchaseButtonComponentView: View {
_ = try await self.purchaseHandler.purchase(package: selectedPackage)
} label: {
// Not passing an onDismiss - nothing in this stack should be able to dismiss
StackComponentView(viewModel: viewModel.stackViewModel, onDismiss: {})
StackComponentView(
viewModel: viewModel.stackViewModel,
onDismiss: {},
showActivityIndicatorOverContent: self.purchaseHandler.actionInProgress
)
}
.disabled(self.purchaseHandler.actionInProgress)
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,23 +33,29 @@ struct StackComponentView: View {
@Environment(\.screenCondition)
private var screenCondition

@Environment(\.colorScheme)
private var colorScheme

private let viewModel: StackComponentViewModel
private let isScrollableByDefault: Bool
private let onDismiss: () -> Void
/// Used when this stack needs more padding than defined in the component, e.g. to avoid being drawn in the safe
/// area when displayed as a sticky footer.
private let additionalPadding: EdgeInsets
private let showActivityIndicatorOverContent: Bool

init(
viewModel: StackComponentViewModel,
isScrollableByDefault: Bool = false,
onDismiss: @escaping () -> Void,
additionalPadding: EdgeInsets? = nil
additionalPadding: EdgeInsets? = nil,
showActivityIndicatorOverContent: Bool = false
) {
self.viewModel = viewModel
self.isScrollableByDefault = isScrollableByDefault
self.onDismiss = onDismiss
self.additionalPadding = additionalPadding ?? EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)
self.showActivityIndicatorOverContent = showActivityIndicatorOverContent
}

var body: some View {
Expand All @@ -67,6 +73,7 @@ struct StackComponentView: View {
}

@ViewBuilder
// swiftlint:disable:next function_body_length
private func make(style: StackComponentStyle) -> some View {
Group {
switch style.dimension {
Expand Down Expand Up @@ -109,8 +116,12 @@ struct StackComponentView: View {
verticalAlignment: alignment.stackAlignment)
}
}
.hidden(if: self.showActivityIndicatorOverContent)
.padding(style.padding)
.padding(additionalPadding)
.applyIf(self.showActivityIndicatorOverContent, apply: { view in
view.progressOverlay(for: style.backgroundStyle)
})
.shape(border: nil,
shape: style.shape,
background: style.backgroundStyle,
Expand Down Expand Up @@ -495,6 +506,50 @@ struct StackComponentView_Previews: PreviewProvider {
.previewLayout(.sizeThatFits)
.previewDisplayName("Scrollable - HStack")

// Progress
let colorOptions: [(String, String, PaywallComponent.ColorInfo)] = [
("Solid color - white tint", "#ffffff", .hex("#ff0000")),
("Solid color - black tint", "#000000", .hex("#f784ff")),
("Gradient - white tint", "#ffffff", .linear(0, [
.init(color: "#1a2494", percent: 0),
.init(color: "#380303", percent: 80)
])),
("Gradient - black tint", "#000000", .linear(0, [
.init(color: "#d6ea92", percent: 0),
.init(color: "#6cacef", percent: 80)
]))
]
ForEach(colorOptions, id: \.self.0) { colorPair in
StackComponentView(
// swiftlint:disable:next force_try
viewModel: try! .init(
component: .init(
components: [
.text(.init(
text: "text_1",
color: .init(light: .hex(colorPair.1))))
],
size: .init(
width: .fill,
height: .fixed(100)
),
backgroundColor: .init(light: colorPair.2)
),
localizationProvider: .init(
locale: Locale.current,
localizedStrings: [
"text_1": .string("Hey")
]
)
),
onDismiss: {},
showActivityIndicatorOverContent: true
)
.previewRequiredEnvironmentProperties()
.previewLayout(.sizeThatFits)
.previewDisplayName("Progress - \(colorPair.0)")
}

// Fits don't expand
StackComponentView(
// swiftlint:disable:next force_try
Expand Down
100 changes: 100 additions & 0 deletions RevenueCatUI/Templates/V2/ViewHelpers/ProgressViewModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//
// 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
//
// ProgressViewModifier.swift
//
// Created by Josh Holtz on 2/13/25.

#if !os(macOS) && !os(tvOS) // For Paywalls V2

import SwiftUI

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
struct ProgressViewModifier: ViewModifier {

@Environment(\.colorScheme)
private var colorScheme

var backgroundStyle: BackgroundStyle?

func body(content: Content) -> some View {
content
#if !os(watchOS)
.background(.ultraThinMaterial)
#endif
.overlay(progressView)
}

@ViewBuilder
private var progressView: some View {
switch backgroundStyle {
case .color(let displayableColorScheme):
let colorInfo = displayableColorScheme.effectiveColor(for: colorScheme)
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: bestTint(for: colorInfo)))
case .image, .none:
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if the tint here should depend on the colorScheme and/or on something else. You know, to prevent showing the while progress view on a white background. But then, I don't even know if this case is even possible (looking at the Dashboard, it seems like Buttons only support color-based backgrounds)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea I think the best version would be to base the tint on the image pixels underneath the circular progress view. But that can also come in a next iteration imo.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or actually, @joshdholtz are there any plans on making the loading indicator have a configurable color on the dashboard? That would avoid the need for us to be smart about it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If not, another way of trying to be smart about it could be to try and use the same color as the (first?) text component in the button. But I'm sure there are edge cases for this (e.g. no text or multiple text components). Just putting ideas out there

Copy link
Member Author

@joshdholtz joshdholtz Feb 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are there any plans on making the loading indicator have a configurable color

@JayShortway I didn't want to because its not something our users should have to think about but I'm not opposed if we make it an optional style at some point

Not sure if the tint here should depend on the colorScheme and/or on something else

@ajpallares The calculated tint does take the color scheme into effect so we should be good!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I added a blur to the background it so it should make it even easier for either tint color to be visible 🤞

}
}

private func bestTint(for colorInfo: DisplayableColorInfo) -> Color {
switch colorInfo {
case .hex:
return colorInfo.toColor(fallback: .black).brightness() > 0.6 ? .black : .white
case .linear, .radial:
let gradient = colorInfo.toGradient()
let averageBrightness = gradient.stops
.compactMap { $0.color.brightness() }
.reduce(0, +) / CGFloat(gradient.stops.count)
return averageBrightness > 0.6 ? .black : .white
Comment on lines +52 to +56
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is ok as a first version and will work 99%, but perhaps we could try and be smarter about this, in the sense of giving the colors in the center a higher weight when computing the averageBrightness than to the colors near the edges (because the ProgressView will appear in the center). Although to get a counterexample of this it probably means chosing a bad gradient combination 😄

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would love to be smarter about this in the future 😅 It's not perfect but its something 🤷‍♂️

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally! :shipit: 🚀

}
}

}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private extension Color {

/// Calculates the perceived brightness of the color.
/// Uses the standard luminance formula for relative brightness perception.
func brightness() -> CGFloat {
#if os(macOS)
guard let nsColor = NSColor(self).usingColorSpace(.deviceRGB) else { return 1.0 }
let red = nsColor.redComponent
let green = nsColor.greenComponent
let blue = nsColor.blueComponent
#else
guard let uiColor = UIColor(self).cgColor.components, uiColor.count >= 3 else { return 1.0 }
let red = uiColor[0]
let green = uiColor[1]
let blue = uiColor[2]
#endif

// Standard luminance coefficients for sRGB (per ITU-R BT.709)
let redCoefficient: CGFloat = 0.299
let greenCoefficient: CGFloat = 0.587
let blueCoefficient: CGFloat = 0.114

// Compute brightness using the weighted sum of RGB components
return (red * redCoefficient) + (green * greenCoefficient) + (blue * blueCoefficient)
}

}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
extension View {

func progressOverlay(for backgroundStyle: BackgroundStyle?) -> some View {
self.modifier(ProgressViewModifier(backgroundStyle: backgroundStyle))
}

}

#endif