Files
purchases-ios/RevenueCatUI/Modifiers/ViewExtensions.swift
T
Antonio Pallares 7785df9afb Fix paywall refreshable bug (#4793)
* add refreshable action to paywalls list in PaywallsTester app

* fix: disable refreshable action in PaywallView

* chore: add comment explaining why `refreshable` action was added to `APIKeyDashboardList`
2025-02-19 10:08:19 +01:00

436 lines
12 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
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
}
}
// 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 swift(>=5.8)
if #available(iOS 16.4, macOS 13.3, tvOS 16.4, watchOS 9.4, *) {
self.scrollBounceBehavior(.basedOnSize)
} else {
self
}
#else
self
#endif
}
@ViewBuilder
// @PublicForExternalTesting
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.
@ViewBuilder
func scrollableIfNecessaryWhenAvailable(_ 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
.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)
}
}
// 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 {
@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