mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-10 22:45:55 +05:45
* Honor explicit no-scroll on root z-layer paywalls * Keep z-layer scroll helper within lint limits * Preserve scroll defaults and apply overflow rules with layout regression coverage * Register overflow rule tests in the Xcode project * Host root scroll layout snapshots in Emerge * Verify snapshot uploads and resolve PR metadata on demand * Use CI branch metadata for snapshot PR lookup * Isolate newer Emerge client for reliable snapshot uploads * Keep snapshot migration on the existing Emerge client * Reuse existing snapshot recording plan and upload lane * Cover root scroll layouts with automatic Emerge previews * Fix fit-size scrolling stack alignment
551 lines
16 KiB
Swift
551 lines
16 KiB
Swift
////
|
|
// 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
|
|
//
|
|
// ViewExtensions.swift
|
|
//
|
|
// Created by Nacho Soto on 7/13/23.
|
|
|
|
// swiftlint:disable file_length
|
|
|
|
import Foundation
|
|
@_spi(Internal) import RevenueCat
|
|
import SwiftUI
|
|
|
|
extension View {
|
|
|
|
@ViewBuilder
|
|
func hidden(if condition: Bool) -> some View {
|
|
if condition {
|
|
self.hidden()
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
|
|
extension View {
|
|
|
|
/// Wraps the 2 `onChange(of:)` implementations in iOS 17+ and below depending on what's available
|
|
@inlinable
|
|
@ViewBuilder
|
|
func onChangeOf<V>(
|
|
_ value: V,
|
|
perform action: @escaping (_ newValue: V) -> Void
|
|
) -> some View where V: Equatable {
|
|
#if swift(>=5.9)
|
|
// wrapping with AnyView to type erase is needed because when archiving an xcframework,
|
|
// the compiler gets confused between the types returned
|
|
// by the different implementations of self.onChange(of:value).
|
|
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) {
|
|
AnyView(self.onChange(of: value) { _, newValue in action(newValue) })
|
|
} else {
|
|
AnyView(self.onChange(of: value) { newValue in action(newValue) })
|
|
}
|
|
#else
|
|
self.onChange(of: value) { newValue in action(newValue) }
|
|
#endif
|
|
}
|
|
|
|
}
|
|
|
|
enum ChangeOf<Value> {
|
|
case new(Value)
|
|
case changed(old: Value, new: Value)
|
|
}
|
|
|
|
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
|
|
extension View {
|
|
|
|
@ViewBuilder
|
|
func onChangeOfWithChange<V>(
|
|
_ value: V,
|
|
perform action: @escaping (_ change: ChangeOf<V>) -> Void
|
|
) -> some View where V: Equatable {
|
|
#if swift(>=5.9)
|
|
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) {
|
|
AnyView(self.onChange(of: value) { old, new in
|
|
action(.changed(old: old, new: new))
|
|
})
|
|
} else {
|
|
AnyView(self.onChange(of: value) { new in
|
|
action(.new(new))
|
|
})
|
|
}
|
|
#else
|
|
self.onChange(of: value) { new in
|
|
action(.new(new))
|
|
}
|
|
#endif
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Scrolling
|
|
|
|
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
|
|
extension View {
|
|
|
|
@ViewBuilder
|
|
func scrollable(
|
|
_ axes: Axis.Set = .vertical,
|
|
if condition: Bool
|
|
) -> some View {
|
|
if condition {
|
|
ScrollView(axes) {
|
|
self
|
|
}
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
func scrollBounceBehaviorBasedOnSize() -> some View {
|
|
if #available(iOS 16.4, macOS 13.3, tvOS 16.4, watchOS 9.4, *) {
|
|
self.scrollBounceBehavior(.basedOnSize)
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
func scrollableIfNecessary(_ axis: Axis = .vertical, enabled: Bool = true) -> some View {
|
|
if enabled {
|
|
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
|
|
ViewThatFits(in: axis.scrollViewAxis) {
|
|
self
|
|
|
|
ScrollView(axis.scrollViewAxis) {
|
|
self
|
|
}
|
|
}
|
|
} else {
|
|
self.modifier(ScrollableIfNecessaryModifier(axis: axis))
|
|
}
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Equivalent to `scrollableIfNecessary` except that it's always scrollable on iOS 15
|
|
/// to work around issues with that iOS 15 implementation in some instances.
|
|
///
|
|
/// FIT content keeps its natural size until it exceeds the available space.
|
|
/// FILL content expands with alignment; fixed and relative sizes keep their scroll viewport.
|
|
@ViewBuilder
|
|
func scrollableIfNecessaryWhenAvailable(
|
|
_ axis: Axis = .vertical,
|
|
size: PaywallComponent.SizeConstraint,
|
|
alignment: Alignment
|
|
) -> some View {
|
|
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
|
|
if size.isFill {
|
|
// For FILL content: use ViewThatFits to avoid scrolling when possible
|
|
// and to be able to fill the space available and align the content
|
|
ViewThatFits(in: axis.scrollViewAxis) {
|
|
// When content is short: expand to fill space with alignment
|
|
self
|
|
.frame(
|
|
maxWidth: axis == .horizontal ? .infinity : nil,
|
|
maxHeight: axis == .vertical ? .infinity : nil,
|
|
alignment: alignment
|
|
)
|
|
|
|
// When content is too long: scroll it
|
|
ScrollView(axis.scrollViewAxis) {
|
|
self.paywallMarkingVerticalScrollContainer(axis: axis) {
|
|
self
|
|
}
|
|
}
|
|
.scrollBounceBehaviorBasedOnSize()
|
|
}
|
|
} else if case .fit = size {
|
|
// A ScrollView expands along its axis even when its content is short.
|
|
// Prefer the natural content size so the parent can align a FIT stack.
|
|
ViewThatFits(in: axis.scrollViewAxis) {
|
|
self
|
|
ScrollView(axis.scrollViewAxis) {
|
|
self.paywallMarkingVerticalScrollContainer(axis: axis) {
|
|
self
|
|
}
|
|
}
|
|
.scrollBounceBehaviorBasedOnSize()
|
|
}
|
|
} else {
|
|
// Preserve the existing viewport for fixed and relative sizes.
|
|
ScrollView(axis.scrollViewAxis) {
|
|
self.paywallMarkingVerticalScrollContainer(axis: axis) {
|
|
self
|
|
}
|
|
}
|
|
.scrollBounceBehaviorBasedOnSize()
|
|
}
|
|
} else {
|
|
self
|
|
.centeredContent(axis)
|
|
.scrollable(if: true)
|
|
}
|
|
}
|
|
|
|
/// Equivalent to `scrollableIfNecessary` except that it's always scrollable on iOS 15
|
|
/// to work around issues with that iOS 15 implementation in some instances.
|
|
/// This function should be used by v1 paywalls since it doesn't respect fit/fill configurations
|
|
@ViewBuilder
|
|
func scrollableIfNecessaryWhenAvailableForV1(
|
|
_ axis: Axis = .vertical,
|
|
enabled: Bool = true
|
|
) -> some View {
|
|
if enabled {
|
|
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
|
|
ViewThatFits(in: axis.scrollViewAxis) {
|
|
self
|
|
|
|
ScrollView(axis.scrollViewAxis) {
|
|
self
|
|
}
|
|
.scrollBounceBehaviorBasedOnSize()
|
|
}
|
|
} else {
|
|
self
|
|
.centeredContent(axis)
|
|
.scrollable(if: enabled)
|
|
}
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
fileprivate func centeredContent(_ axis: Axis) -> some View {
|
|
switch axis {
|
|
case .horizontal:
|
|
HStack {
|
|
Spacer()
|
|
self
|
|
Spacer()
|
|
}
|
|
case .vertical:
|
|
VStack {
|
|
Spacer()
|
|
self
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Padding
|
|
|
|
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.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, watchOS 8.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, watchOS 8.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, *)
|
|
private struct ScrollableIfNecessaryModifier: ViewModifier {
|
|
|
|
var axis: Axis
|
|
|
|
@State
|
|
private var overflowing: Bool = false
|
|
|
|
func body(content: Content) -> some View {
|
|
GeometryReader { geometry in
|
|
content
|
|
.centeredContent(self.axis)
|
|
.background(
|
|
GeometryReader { contentGeometry in
|
|
Color.clear
|
|
.onAppear {
|
|
switch self.axis {
|
|
case .horizontal:
|
|
self.overflowing = contentGeometry.size.width > geometry.size.width
|
|
case .vertical:
|
|
self.overflowing = contentGeometry.size.height > geometry.size.height
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
.scrollable(self.axis.scrollViewAxis, if: self.overflowing)
|
|
.scrollBounceBehaviorBasedOnSize()
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Size changes
|
|
|
|
extension View {
|
|
|
|
/// Invokes the given closure whethever the view size changes.
|
|
func onSizeChange(_ closure: @escaping (CGSize) -> Void) -> some View {
|
|
self
|
|
.overlay(
|
|
GeometryReader { geometry in
|
|
Color.clear
|
|
.preference(key: ViewSizePreferenceKey.self,
|
|
value: geometry.size)
|
|
}
|
|
)
|
|
.onPreferenceChange(ViewSizePreferenceKey.self, perform: closure)
|
|
}
|
|
|
|
/// Invokes the given closure with the view width whenever it changes.
|
|
@ViewBuilder
|
|
func onWidthChange(
|
|
_ closure: @escaping (CGFloat) -> Void
|
|
) -> some View {
|
|
self
|
|
.overlay(
|
|
GeometryReader { geometry in
|
|
Color.clear
|
|
.preference(
|
|
key: ViewWidthPreferenceKey.self,
|
|
value: geometry.size.width
|
|
)
|
|
}
|
|
)
|
|
.onPreferenceChange(ViewWidthPreferenceKey.self, perform: closure)
|
|
}
|
|
|
|
/// Invokes the given closure with the view height whenever it changes.
|
|
@ViewBuilder
|
|
func onHeightChange(
|
|
_ closure: @escaping (CGFloat) -> Void
|
|
) -> some View {
|
|
self
|
|
.overlay(
|
|
GeometryReader { geometry in
|
|
Color.clear
|
|
.preference(
|
|
key: ViewHeightPreferenceKey.self,
|
|
value: geometry.size.height
|
|
)
|
|
}
|
|
)
|
|
.onPreferenceChange(ViewHeightPreferenceKey.self, perform: closure)
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Rounded corners
|
|
|
|
#if canImport(UIKit)
|
|
|
|
extension View {
|
|
|
|
@ViewBuilder
|
|
func roundedCorner(
|
|
_ radius: CGFloat,
|
|
corners: UIRectCorner,
|
|
edgesIgnoringSafeArea edges: Edge.Set = []
|
|
) -> some View {
|
|
if radius > 0 {
|
|
#if swift(>=5.9)
|
|
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
|
|
self.mask(
|
|
UnevenRoundedRectangle(radius: radius, corners: corners),
|
|
edgesIgnoringSafeArea: edges
|
|
)
|
|
} else {
|
|
self.mask(
|
|
RoundedCorner(radius: radius, corners: corners),
|
|
edgesIgnoringSafeArea: edges
|
|
)
|
|
}
|
|
#else
|
|
self.mask(
|
|
RoundedCorner(radius: radius, corners: corners),
|
|
edgesIgnoringSafeArea: edges
|
|
)
|
|
#endif
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
|
|
private func mask(_ shape: some Shape, edgesIgnoringSafeArea edges: Edge.Set) -> some View {
|
|
self.mask(shape.edgesIgnoringSafeArea(edges))
|
|
}
|
|
|
|
}
|
|
|
|
private struct RoundedCorner: Shape {
|
|
|
|
var radius: CGFloat
|
|
var corners: UIRectCorner
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
let path = UIBezierPath(roundedRect: rect,
|
|
byRoundingCorners: self.corners,
|
|
cornerRadii: CGSize(width: self.radius, height: self.radius))
|
|
return Path(path.cgPath)
|
|
}
|
|
|
|
}
|
|
|
|
#endif
|
|
|
|
// MARK: - Disabling Refreshable
|
|
|
|
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
|
|
extension View {
|
|
|
|
/// Disables the refreshable action for a view.
|
|
/// - Returns: A view with the refreshable action removed.
|
|
///
|
|
/// This is useful when you want to disable the refreshable action for a view that may inherit it from its
|
|
/// container.
|
|
///
|
|
/// # Use case
|
|
/// When a `PaywallView` is presented, the presenting view may have a refreshable action. If the `refreshable`
|
|
/// modifier is applied **after** the modifier that presents the `PaywallView` (e.g. with `.sheet`), then the
|
|
/// `PaywallView` will also inherit the refreshable action.
|
|
/// ```swift
|
|
/// contentView
|
|
/// .sheet(isPresented: $paywallPresented) {
|
|
/// PaywallView(offering: offering)
|
|
/// }
|
|
/// .refreshable {
|
|
/// // Some async code to refresh contentView
|
|
/// }
|
|
/// ```
|
|
///
|
|
/// `PaywallView` uses this `refreshableDisabled()` modifier to disable the inherited refreshable action, if any.
|
|
@ViewBuilder
|
|
func refreshableDisabled() -> some View {
|
|
if let refreshKeyPath = \EnvironmentValues.refresh as? WritableKeyPath<EnvironmentValues, RefreshAction?> {
|
|
self.environment(refreshKeyPath, nil)
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Preference Keys
|
|
|
|
private protocol ViewDimensionPreferenceKey: PreferenceKey where Value == CGFloat {}
|
|
|
|
extension ViewDimensionPreferenceKey {
|
|
|
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
let newValue = max(value, nextValue())
|
|
if newValue != value {
|
|
value = newValue
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
/// `PreferenceKey` for keeping track of a view width.
|
|
private struct ViewWidthPreferenceKey: ViewDimensionPreferenceKey {
|
|
|
|
static var defaultValue: Value = 10
|
|
|
|
}
|
|
|
|
/// `PreferenceKey` for keeping track of a view height.
|
|
private struct ViewHeightPreferenceKey: ViewDimensionPreferenceKey {
|
|
|
|
static var defaultValue: Value = 10
|
|
|
|
}
|
|
|
|
/// `PreferenceKey` for keeping track of view size.
|
|
private struct ViewSizePreferenceKey: PreferenceKey {
|
|
|
|
typealias Value = CGSize
|
|
|
|
static var defaultValue: Value = .init(width: 10, height: 10)
|
|
|
|
static func reduce(value: inout Value, nextValue: () -> Value) {
|
|
value = nextValue()
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private extension Axis {
|
|
|
|
var scrollViewAxis: Axis.Set {
|
|
switch self {
|
|
case .horizontal: return .horizontal
|
|
case .vertical: return .vertical
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
#if swift(>=5.9) && canImport(UIKit)
|
|
@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
|
|
private extension UnevenRoundedRectangle {
|
|
|
|
/// Creates a `UnevenRoundedRectangle` rounding `corners` with `radius`.
|
|
/// - Note: this does not take RTL into account.
|
|
init(radius: CGFloat, corners: UIRectCorner) {
|
|
self.init(
|
|
topLeadingRadius: corners.contains(.topLeft) ? radius : 0,
|
|
bottomLeadingRadius: corners.contains(.bottomLeft) ? radius : 0,
|
|
bottomTrailingRadius: corners.contains(.bottomRight) ? radius : 0,
|
|
topTrailingRadius: corners.contains(.topRight) ? radius : 0,
|
|
style: .continuous
|
|
)
|
|
}
|
|
}
|
|
#endif
|