Repo // Add Shaps Benkau's package "SwiftUIBackports".

This commit is contained in:
ShikiSuen 2022-10-01 14:48:43 +08:00
parent 42f2b9ead6
commit 774e3d5e8d
72 changed files with 6930 additions and 0 deletions

View File

@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -0,0 +1,5 @@
version: 1.8.2
builder:
configs:
- platform: ios
documentation_targets: [SwiftUIBackports]

View File

@ -0,0 +1,9 @@
MIT License
Copyright (c) 2021 Shaps Benkau
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,22 @@
// swift-tools-version: 5.5
import PackageDescription
let package = Package(
name: "SwiftUIBackports",
platforms: [
.iOS(.v13),
.tvOS(.v13),
.watchOS(.v6),
.macOS(.v10_15),
],
products: [
.library(
name: "SwiftUIBackports",
targets: ["SwiftUIBackports"]
)
],
targets: [
.target(name: "SwiftUIBackports")
],
swiftLanguageVersions: [.v5]
)

View File

@ -0,0 +1,131 @@
## NOTICE
- This package copy is clang-formatted according to vChewing's clang-format style, with removal of certain dependencies / features not-required by vChewing.
- **If you want to use this package**, you might want to consult its original repository: https://github.com/shaps80/SwiftUIBackports
![watchOS](https://img.shields.io/badge/watchOS-DE1F51)
![macOS](https://img.shields.io/badge/macOS-EE751F)
![tvOS](https://img.shields.io/badge/tvOS-00B9BB)
![ios](https://img.shields.io/badge/iOS-0C62C7)
[![swift](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fshaps80%2FSwiftUIBackports%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/shaps80/SwiftUIBackports)
# SwiftUI Backports
Introducing a collection of SwiftUI backports to make your iOS development easier.
Many backports support iOS 13+ but where UIKIt features were introduced in later versions, the same will be applicable to these backports, to keep parity with UIKit.
In some cases, I've also included additional APIs that bring more features to your SwiftUI development.
> Note, **all** backports will be API-matching to Apple's offical APIs, any additional features will be provided separately.
All backports are fully documented, in most cases using Apple's own documentation for consistency. Please refer to the header docs or Apple's original documentation for more details.
There is also a [Demo project](https://github.com/shaps80/SwiftUIBackportsDemo) available where you can see full demonstrations of all backports and additional features, including reference code to help you get started.
> Lastly, I hope this repo also serves as a great resource for _how_ you can backport effectively with minimal hacks 👍
## Sponsor
Building useful libraries like these, takes time away from my family. I build these tools in my spare time because I feel its important to give back to the community. Please consider [Sponsoring](https://github.com/sponsors/shaps80) me as it helps keep me working on useful libraries like these 😬
You can also give me a follow and a 'thanks' anytime.
[![Twitter](https://img.shields.io/badge/Twitter-@shaps-4AC71B)](http://twitter.com/shaps)
## Usage
The library adopts a backport design by [Dave DeLong](https://davedelong.com/blog/2021/10/09/simplifying-backwards-compatibility-in-swift/) that makes use of a single type to improve discoverability and maintainability when the time comes to remove your backport implementations, in favour of official APIs.
Backports of pure types, can easily be discovered under the `Backport` namespace. Similarly, modifiers are discoverable under the `.backport` namespace.
> Unfortuantely `Environment` backports cannot be access this way, in those cases the Apple API values will be prefixed with `backport` to simplify discovery.
Types:
```swift
@Backport.AppStorage("filter-enabled")
private var filterEnabled: Bool = false
```
Modifier:
```swift
Button("Show Prompt") {
showPrompt = true
}
.sheet(isPresented: $showPrompt) {
Prompt()
.backport.presentationDetents([.medium, .large])
}
```
Environment:
```swift
@Environment(\.backportRefresh) private var refreshAction
```
## Backports
**SwiftUI**
- `asyncImage`
- `AppStorage`
- `background` ViewBuilder API
- `DismissAction`
- `DynamicTypeSize`
`Label`
`LabeledContent`
- `NavigationDestination` uses a standard NavigationView
- `navigationTitle` newer API
- `overlay` ViewBuilder API
- `onChange`
- `openURL`
- `ProgressView`
- `presentationDetents`
- `presentationDragIndicator`
- `quicklookPreview`
- `requestReview`
- `Refreshable` includes pull-to-refresh 
- `ScaledMetric`
- `StateObject`
- `scrollDisabled`
- `scrollDismissesKeyboard`
- `scrollIndicators`
- `Section(_ header:)`
- `task` async/await modifier
**UIKit**
- `UIHostingConfiguration` simplifies embedding SwiftUI in `UICollectionViewCell` and `UITableViewCell`
## Extras
**Modal Presentations**
Adding this to your presented view, you can use the provided closure to present an `ActionSheet` to a user when they attempt to dismiss interactively. You can also use this to disable interactive dismissals entirely.
```swift
presentation(isModal: true) { /* attempt */ }
```
**FittingGeometryReader**
A custom `GeometryReader` implementation that correctly auto-sizes itself to its content. This is useful in many cases where you need a `GeometryReader` but don't want it to implicitly take up its parent View's bounds.
**FittingScrollView**
A custom `ScrollView` that respects `Spacer`'s when the content is not scrollable. This is useful when you need to place a view at the edges of your scrollview while its content is small enough to not require scrolling. Another great use case is vertically centered content that becomes `top` aligned once the content requires scrolling.
**PageView**
A pure SwiftUI implementation of a page-based view, using the native `TabView` and my custom `FittingGeometryReader` to size itself correctly. Since this uses a `TabView` under-the-hood, this allows you to use the same APIs and features from that view.
## Installation
You can install manually (by copying the files in the `Sources` directory) or using Swift Package Manager (**preferred**)
To install using Swift Package Manager, add this to the `dependencies` section of your `Package.swift` file:
`.package(url: "https://github.com/shaps80/SwiftUIBackports.git", .upToNextMinor(from: "1.0.0"))`

View File

@ -0,0 +1,57 @@
import ObjectiveC
import SwiftUI
/// Provides a convenient method for backporting API,
/// including types, functions, properties, property wrappers and more.
///
/// To backport a SwiftUI Label for example, you could apply the
/// following extension:
///
/// extension Backport where Content == Any {
/// public struct Label<Title, Icon> { }
/// }
///
/// Now if we want to provide further extensions to our backport type,
/// we need to ensure we retain the `Content == Any` generic requirement:
///
/// extension Backport.Label where Content == Any, Title == Text, Icon == Image {
/// public init<S: StringProtocol>(_ title: S, systemName: String) { }
/// }
///
/// In addition to types, we can also provide backports for properties
/// and methods:
///
/// extension Backport.Label where Content: View {
/// func onChange<Value: Equatable>(of value: Value, perform action: (Value) -> Void) -> some View {
/// // `content` provides access to the extended type
/// content.modifier(OnChangeModifier(value, action))
/// }
/// }
///
public struct Backport<Wrapped> {
/// The underlying content this backport represents.
public let content: Wrapped
/// Initializes a new Backport for the specified content.
/// - Parameter content: The content (type) that's being backported
public init(_ content: Wrapped) {
self.content = content
}
}
extension View {
/// Wraps a SwiftUI `View` that can be extended to provide backport functionality.
public var backport: Backport<Self> { .init(self) }
}
extension NSObjectProtocol {
/// Wraps an `NSObject` that can be extended to provide backport functionality.
public var backport: Backport<Self> { .init(self) }
}
extension AnyTransition {
/// Wraps an `AnyTransition` that can be extended to provide backport functionality.
public static var backport: Backport<AnyTransition> {
Backport(.identity)
}
}

View File

@ -0,0 +1,47 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
/// A geometry reader that automatically sizes its height to 'fit' its content.
public struct FittingGeometryReader<Content>: View where Content: View {
@State private var height: CGFloat = 10 // must be non-zero
private var content: (GeometryProxy) -> Content
public init(@ViewBuilder content: @escaping (GeometryProxy) -> Content) {
self.content = content
}
public var body: some View {
GeometryReader { geo in
content(geo)
.fixedSize(horizontal: false, vertical: true)
.modifier(SizeModifier())
.onPreferenceChange(SizePreferenceKey.self) {
height = $0.height
}
}
.frame(height: height)
}
}
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
private struct SizeModifier: ViewModifier {
func body(content: Content) -> some View {
content.overlay(
GeometryReader { geo in
Color.clear.preference(
key: SizePreferenceKey.self,
value: geo.size
)
}
)
}
}

View File

@ -0,0 +1,34 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
/// A scrollview that behaves more similarly to a `VStack` when its content size is small enough.
public struct FittingScrollView<Content: View>: View {
private let content: Content
private let showsIndicators: Bool
/// A new scrollview
/// - Parameters:
/// - showsIndicators: If true, the scroll view will show indicators when necessary
/// - content: The content for this scroll view
public init(showsIndicators: Bool = true, @ViewBuilder content: () -> Content) {
self.showsIndicators = showsIndicators
self.content = content()
}
public var body: some View {
GeometryReader { geo in
SwiftUI.ScrollView(showsIndicators: showsIndicators) {
VStack(spacing: 10) {
content
}
.frame(
maxWidth: geo.size.width,
minHeight: geo.size.height
)
}
}
}
}

View File

@ -0,0 +1,45 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(tvOS, deprecated: 13)
@available(macOS, deprecated: 10.15)
@available(watchOS, deprecated: 6)
extension View {
/// Sets whether this presentation should act as a `modal`, preventing interactive dismissals
/// - Parameter isModal: If `true` the user will not be able to interactively dismiss
@ViewBuilder
@available(iOS, deprecated: 13, renamed: "backport.interactiveDismissDisabled(_:)")
public func presentation(isModal: Bool) -> some View {
#if os(iOS)
if #available(iOS 15, *) {
backport.interactiveDismissDisabled(isModal)
} else {
self
}
#else
self
#endif
}
/// Provides fine-grained control over the dismissal.
/// - Parameters:
/// - isModal: If `true`, the user will not be able to interactively dismiss
/// - onAttempt: A closure that will be called when an interactive dismiss attempt occurs.
/// You can use this as an opportunity to present an ActionSheet to prompt the user.
@ViewBuilder
@available(iOS, deprecated: 13, renamed: "backport.interactiveDismissDisabled(_:onAttempt:)")
public func presentation(isModal: Bool = true, _ onAttempt: @escaping () -> Void) -> some View {
#if os(iOS)
if #available(iOS 15, *) {
backport.interactiveDismissDisabled(isModal, onAttempt: onAttempt)
} else {
self
}
#else
self
#endif
}
}

View File

@ -0,0 +1,148 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
extension EnvironmentValues {
fileprivate func containsValue(forKey key: String) -> Bool {
value(forKey: key) != nil
}
fileprivate func value<T>(forKey key: String, from mirror: Mirror, as _: T.Type) -> T? {
// Found a match
if let value = mirror.descendant("value", "some") {
if let typedValue = value as? T {
print("Found value")
return typedValue
} else {
print(
"Value for key '\(key)' in the environment is of type '\(type(of: value))', but we expected '\(String(describing: T.self))'."
)
}
} else {
print(
"Found key '\(key)' in the environment, but it doesn't have the expected structure. The type hierarchy may have changed in your SwiftUI version."
)
}
return nil
}
/// Extracts a value from the environment by the name of its associated EnvironmentKey.
/// Can be used to grab private environment values such as foregroundColor ("ForegroundColorKey").
fileprivate func value<T>(forKey key: String, as _: T.Type) -> T? {
if let mirror = value(forKey: key) as? Mirror {
return value(forKey: key, from: mirror, as: T.self)
} else if let value = value(forKey: key) as? T {
return value
} else {
return nil
}
}
fileprivate func value(forKey key: String) -> Any? {
func keyFromTypeName(typeName: String) -> String? {
let expectedPrefix = "TypedElement<EnvironmentPropertyKey<"
guard typeName.hasPrefix(expectedPrefix) else {
print("Wrong prefix")
return nil
}
let rest = typeName.dropFirst(expectedPrefix.count)
let expectedSuffix = ">>"
guard rest.hasSuffix(expectedSuffix) else {
print("Wrong suffix")
return nil
}
let middle = rest.dropLast(expectedSuffix.count)
return String(middle)
}
/// `environmentMember` has type (for example) `TypedElement<EnvironmentPropertyKey<ForegroundColorKey>>`
/// TypedElement.value contains the value of the key.
func extract(startingAt environmentNode: Any) -> Any? {
let mirror = Mirror(reflecting: environmentNode)
let typeName = String(describing: type(of: environmentNode))
if let nodeKey = keyFromTypeName(typeName: typeName) {
if key == nodeKey {
return mirror
}
}
// Environment values are stored in a doubly linked list. The "before" and "after" keys point
// to the next environment member.
if let linkedListMirror = mirror.superclassMirror,
let nextNode = linkedListMirror.descendant("after", "some")
{
return extract(startingAt: nextNode)
}
return nil
}
let mirror = Mirror(reflecting: self)
if let firstEnvironmentValue = mirror.descendant("_plist", "elements", "some") {
if let node = extract(startingAt: firstEnvironmentValue) {
return node
} else {
return nil
}
} else {
return nil
}
}
}
@propertyWrapper
internal struct StringlyTypedEnvironment<Value> {
final class Store<Value>: ObservableObject {
var value: Value?
}
@Environment(\.self) private var env
@ObservedObject private var store = Store<Value>()
var key: String
init(key: String) {
self.key = key
}
private(set) var wrappedValue: Value? {
get { store.value }
nonmutating set { store.value = newValue }
}
}
extension StringlyTypedEnvironment: DynamicProperty {
func update() {
wrappedValue = env.value(forKey: key, as: Value.self)
}
}
@propertyWrapper
internal struct EnvironmentContains: DynamicProperty {
final class Store: ObservableObject {
var contains: Bool = false
}
@Environment(\.self) private var env
var key: String
@ObservedObject private var store = Store()
init(key: String) {
self.key = key
}
var wrappedValue: Bool {
get { store.contains }
nonmutating set { store.contains = newValue }
}
func update() {
wrappedValue = env.containsValue(forKey: key)
}
}

View File

@ -0,0 +1,41 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
/*
The following code is for debugging purposes only!
*/
#if DEBUG
extension EnvironmentValues: CustomDebugStringConvertible {
public var debugDescription: String {
"\(self)"
.trimmingCharacters(in: .init(["[", "]"]))
.replacingOccurrences(of: "EnvironmentPropertyKey", with: "")
.replacingOccurrences(of: ", ", with: "\n")
}
}
struct EnvironmentOutputModifier: ViewModifier {
@Environment(\.self) private var environment
func body(content: Content) -> some View {
content
.onAppear {
print(environment.debugDescription)
}
}
}
extension View {
func printEnvironment() -> some View {
modifier(EnvironmentOutputModifier())
}
}
#endif

View File

@ -0,0 +1,172 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
#if os(iOS)
internal typealias PlatformView = UIView
internal typealias PlatformViewController = UIViewController
#elseif os(macOS)
internal typealias PlatformView = NSView
internal typealias PlatformViewController = NSViewController
#endif
#if os(iOS) || os(macOS)
extension PlatformView {
func ancestor<ViewType: PlatformView>(ofType _: ViewType.Type) -> ViewType? {
var view = superview
while let s = view {
if let typed = s as? ViewType {
return typed
}
view = s.superview
}
return nil
}
var host: PlatformView? {
var view = superview
while let s = view {
if NSStringFromClass(type(of: s)).contains("ViewHost") {
return s
}
view = s.superview
}
return nil
}
func sibling<ViewType: PlatformView>(ofType type: ViewType.Type) -> ViewType? {
guard let superview = superview, let index = superview.subviews.firstIndex(of: self) else { return nil }
var views = superview.subviews
views.remove(at: index)
for subview in views.reversed() {
if let typed = subview.child(ofType: type) {
return typed
}
}
return nil
}
func child<ViewType: PlatformView>(ofType type: ViewType.Type) -> ViewType? {
for subview in subviews {
if let typed = subview as? ViewType {
return typed
} else if let typed = subview.child(ofType: type) {
return typed
}
}
return nil
}
}
internal struct Inspector {
var hostView: PlatformView
var sourceView: PlatformView
var sourceController: PlatformViewController
func ancestor<ViewType: PlatformView>(ofType _: ViewType.Type) -> ViewType? {
hostView.ancestor(ofType: ViewType.self)
}
func sibling<ViewType: PlatformView>(ofType _: ViewType.Type) -> ViewType? {
hostView.sibling(ofType: ViewType.self)
}
}
extension View {
private func inject<Wrapped>(_ content: Wrapped) -> some View where Wrapped: View {
overlay(content.frame(width: 0, height: 0))
}
func inspect<ViewType: PlatformView>(
selector: @escaping (_ inspector: Inspector) -> ViewType?, customize: @escaping (ViewType) -> Void
) -> some View {
inject(InspectionView(selector: selector, customize: customize))
}
}
private struct InspectionView<ViewType: PlatformView>: View {
let selector: (Inspector) -> ViewType?
let customize: (ViewType) -> Void
var body: some View {
Representable(parent: self)
}
}
private class SourceView: PlatformView {
required init() {
super.init(frame: .zero)
isHidden = true
#if os(iOS)
isUserInteractionEnabled = false
#endif
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
#endif
#if os(iOS)
extension InspectionView {
fileprivate struct Representable: UIViewRepresentable {
let parent: InspectionView
func makeUIView(context _: Context) -> UIView { .init() }
func updateUIView(_ view: UIView, context _: Context) {
DispatchQueue.main.async {
guard let host = view.host else { return }
let inspector = Inspector(
hostView: host,
sourceView: view,
sourceController: view.parentController
?? view.window?.rootViewController
?? UIViewController()
)
guard let targetView = parent.selector(inspector) else { return }
parent.customize(targetView)
}
}
}
}
#elseif os(macOS)
extension InspectionView {
fileprivate struct Representable: NSViewRepresentable {
let parent: InspectionView
func makeNSView(context _: Context) -> NSView {
.init(frame: .zero)
}
func updateNSView(_ view: NSView, context _: Context) {
DispatchQueue.main.async {
guard let host = view.host else { return }
let inspector = Inspector(
hostView: host,
sourceView: view,
sourceController: view.parentController ?? NSViewController(nibName: nil, bundle: nil)
)
guard let targetView = parent.selector(inspector) else { return }
parent.customize(targetView)
}
}
}
}
#endif

View File

@ -0,0 +1,39 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
#if os(iOS)
import UIKit
extension UIView {
public var parentController: UIViewController? {
var responder: UIResponder? = self
while !(responder is UIViewController), superview != nil {
if let next = responder?.next {
responder = next
}
}
return responder as? UIViewController
}
}
#endif
#if os(macOS)
import AppKit
extension NSView {
public var parentController: NSViewController? {
var responder: NSResponder? = self
while !(responder is NSViewController), superview != nil {
if let next = responder?.nextResponder {
responder = next
}
}
return responder as? NSViewController
}
}
#endif

View File

@ -0,0 +1,47 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
#if os(iOS) || os(tvOS)
/*
Since UICollectionView is not designed to support SwiftUI out of the box,
we need to use a little trick to get the SwiftUI View's to ignore safeArea
insets, otherwise our cell's will not always layout correctly.
*/
extension UIHostingController {
convenience init(rootView: Content, ignoreSafeArea: Bool) {
self.init(rootView: rootView)
if ignoreSafeArea {
disableSafeArea()
}
}
func disableSafeArea() {
guard let viewClass = object_getClass(view) else { return }
let viewSubclassName = String(cString: class_getName(viewClass)).appending("_IgnoreSafeArea")
if let viewSubclass = NSClassFromString(viewSubclassName) {
object_setClass(view, viewSubclass)
} else {
guard let viewClassNameUtf8 = (viewSubclassName as NSString).utf8String else { return }
guard let viewSubclass = objc_allocateClassPair(viewClass, viewClassNameUtf8, 0) else { return }
if let method = class_getInstanceMethod(UIView.self, #selector(getter:UIView.safeAreaInsets)) {
let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { _ in
.zero
}
class_addMethod(
viewSubclass, #selector(getter:UIView.safeAreaInsets), imp_implementationWithBlock(safeAreaInsets),
method_getTypeEncoding(method)
)
}
objc_registerClassPair(viewSubclass)
object_setClass(view, viewSubclass)
}
}
}
#endif

View File

@ -0,0 +1,15 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
#if os(iOS)
import UIKit
extension UIApplication {
static var activeScene: UIWindowScene? {
shared.connectedScenes
.first { $0.activationState == .foregroundActive }
as? UIWindowScene
}
}
#endif

View File

@ -0,0 +1,367 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14)
@available(watchOS, deprecated: 7)
extension Backport where Wrapped == Any {
/// A property wrapper type that reflects a value from `Store` and
/// invalidates a view on a change in value in that store.
@propertyWrapper
public struct AppStorage<Value>: DynamicProperty {
@ObservedObject
private var _value: RefStorage<Value>
private let commitHandler: (Value) -> Void
public var wrappedValue: Value {
get { _value.value }
nonmutating set {
commitHandler(newValue)
_value.value = newValue
}
}
public var projectedValue: Binding<Value> {
Binding(
get: { wrappedValue },
set: { wrappedValue = $0 }
)
}
private init(
value: Value, store: UserDefaults, key: String, get: @escaping (Any?) -> Value?, set: @escaping (Value) -> Void
) {
_value = RefStorage(value: value, store: store, key: key, transform: get)
commitHandler = set
}
}
}
extension Backport.AppStorage {
/// Creates a property that can read and write to a boolean user default.
///
/// - Parameters:
/// - wrappedValue: The default value if a boolean value is not specified
/// for the given key.
/// - key: The key to read and write the value to in the store
/// store.
/// - store: The store to read and write to. A value
/// of `nil` will use the user default store from the environment.
public init(wrappedValue: Value, _ key: String, store: UserDefaults = .standard) where Value == Bool {
let value = store.value(forKey: key) as? Value ?? wrappedValue
self.init(
value: value, store: store, key: key,
get: { $0 as? Value },
set: { store.set($0, forKey: key) }
)
}
/// Creates a property that can read and write to an integer user default.
///
/// - Parameters:
/// - wrappedValue: The default value if an integer value is not specified
/// for the given key.
/// - key: The key to read and write the value to in the store
/// store.
/// - store: The store to read and write to. A value
/// of `nil` will use the user default store from the environment.
public init(wrappedValue: Value, _ key: String, store: UserDefaults = .standard) where Value == Int {
let value = store.value(forKey: key) as? Value ?? wrappedValue
self.init(
value: value, store: store, key: key,
get: { $0 as? Value },
set: { store.set($0, forKey: key) }
)
}
/// Creates a property that can read and write to a double user default.
///
/// - Parameters:
/// - wrappedValue: The default value if a double value is not specified
/// for the given key.
/// - key: The key to read and write the value to in the store
/// store.
/// - store: The store to read and write to. A value
/// of `nil` will use the user default store from the environment.
public init(wrappedValue: Value, _ key: String, store: UserDefaults = .standard) where Value == Double {
let value = store.value(forKey: key) as? Value ?? wrappedValue
self.init(
value: value, store: store, key: key,
get: { $0 as? Value },
set: { store.set($0, forKey: key) }
)
}
/// Creates a property that can read and write to a string user default.
///
/// - Parameters:
/// - wrappedValue: The default value if a string value is not specified
/// for the given key.
/// - key: The key to read and write the value to in the store
/// store.
/// - store: The store to read and write to. A value
/// of `nil` will use the user default store from the environment.
public init(wrappedValue: Value, _ key: String, store: UserDefaults = .standard) where Value == String {
let value = store.value(forKey: key) as? Value ?? wrappedValue
self.init(
value: value, store: store, key: key,
get: { $0 as? Value },
set: { store.set($0, forKey: key) }
)
}
/// Creates a property that can read and write to a url user default.
///
/// - Parameters:
/// - wrappedValue: The default value if a url value is not specified for
/// the given key.
/// - key: The key to read and write the value to in the store
/// store.
/// - store: The store to read and write to. A value
/// of `nil` will use the user default store from the environment.
public init(wrappedValue: Value, _ key: String, store: UserDefaults = .standard) where Value == URL {
let value = store.url(forKey: key) ?? wrappedValue
self.init(
value: value, store: store, key: key,
get: { ($0 as? String).flatMap(URL.init) },
set: { store.set($0.absoluteString, forKey: key) }
)
}
/// Creates a property that can read and write to a user default as data.
///
/// Avoid storing large data blobs in store, such as image data,
/// as it can negatively affect performance of your app.
///
/// - Parameters:
/// - wrappedValue: The default value if a data value is not specified for
/// the given key.
/// - key: The key to read and write the value to in the store
/// store.
/// - store: The store to read and write to. A value
/// of `nil` will use the user default store from the environment.
public init(wrappedValue: Value, _ key: String, store: UserDefaults = .standard) where Value == Data {
let value = store.value(forKey: key) as? Data ?? wrappedValue
self.init(
value: value, store: store, key: key,
get: { $0 as? Value },
set: { store.set($0, forKey: key) }
)
}
}
extension Backport.AppStorage where Wrapped == Any, Value: ExpressibleByNilLiteral {
/// Creates a property that can read and write an Optional boolean user
/// default.
///
/// Defaults to nil if there is no restored value.
///
/// - Parameters:
/// - key: The key to read and write the value to in the store
/// store.
/// - store: The store to read and write to. A value
/// of `nil` will use the user default store from the environment.
public init(_ key: String, store: UserDefaults = .standard) where Value == Bool? {
let value = store.value(forKey: key) as? Value ?? .none
self.init(
value: value, store: store, key: key,
get: { $0 as? Value },
set: { store.set($0, forKey: key) }
)
}
/// Creates a property that can read and write an Optional integer user
/// default.
///
/// Defaults to nil if there is no restored value.
///
/// - Parameters:
/// - key: The key to read and write the value to in the store
/// store.
/// - store: The store to read and write to. A value
/// of `nil` will use the user default store from the environment.
public init(_ key: String, store: UserDefaults = .standard) where Value == Int? {
let value = store.value(forKey: key) as? Value ?? .none
self.init(
value: value, store: store, key: key,
get: { $0 as? Value },
set: { store.set($0, forKey: key) }
)
}
/// Creates a property that can read and write an Optional double user
/// default.
///
/// Defaults to nil if there is no restored value.
///
/// - Parameters:
/// - key: The key to read and write the value to in the store
/// store.
/// - store: The store to read and write to. A value
/// of `nil` will use the user default store from the environment.
public init(_ key: String, store: UserDefaults = .standard) where Value == Double? {
let value = store.value(forKey: key) as? Value ?? .none
self.init(
value: value, store: store, key: key,
get: { $0 as? Value },
set: { store.set($0, forKey: key) }
)
}
/// Creates a property that can read and write an Optional string user
/// default.
///
/// Defaults to nil if there is no restored value.
///
/// - Parameters:
/// - key: The key to read and write the value to in the store
/// store.
/// - store: The store to read and write to. A value
/// of `nil` will use the user default store from the environment.
public init(_ key: String, store: UserDefaults = .standard) where Value == String? {
let value = store.value(forKey: key) as? Value ?? .none
self.init(
value: value, store: store, key: key,
get: { $0 as? Value },
set: { store.set($0, forKey: key) }
)
}
/// Creates a property that can read and write an Optional URL user
/// default.
///
/// Defaults to nil if there is no restored value.
///
/// - Parameters:
/// - key: The key to read and write the value to in the store
/// store.
/// - store: The store to read and write to. A value
/// of `nil` will use the user default store from the environment.
public init(_ key: String, store: UserDefaults = .standard) where Value == URL? {
let value = store.url(forKey: key) ?? .none
self.init(
value: value, store: store, key: key,
get: { ($0 as? String).flatMap(URL.init) },
set: { store.set($0?.absoluteString, forKey: key) }
)
}
/// Creates a property that can read and write an Optional data user
/// default.
///
/// Defaults to nil if there is no restored value.
///
/// - Parameters:
/// - key: The key to read and write the value to in the store
/// store.
/// - store: The store to read and write to. A value
/// of `nil` will use the user default store from the environment.
public init(_ key: String, store: UserDefaults = .standard) where Value == Data? {
let value = store.value(forKey: key) as? Value ?? .none
self.init(
value: value, store: store, key: key,
get: { $0 as? Value },
set: { store.set($0, forKey: key) }
)
}
}
extension Backport.AppStorage where Wrapped == Any, Value: RawRepresentable {
/// Creates a property that can read and write to a string user default,
/// transforming that to `RawRepresentable` data type.
///
/// A common usage is with enumerations:
///
/// enum MyEnum: String {
/// case a
/// case b
/// case c
/// }
///
/// @AppStorage("MyEnumValue") private var value = MyEnum.a
///
/// - Parameters:
/// - wrappedValue: The default value if a string value
/// is not specified for the given key.
/// - key: The key to read and write the value to in the store
/// store.
/// - store: The store to read and write to. A value
/// of `nil` will use the user default store from the environment.
public init(wrappedValue: Value, _ key: String, store: UserDefaults = .standard) where Value.RawValue == String {
let rawValue = store.value(forKey: key) as? Value.RawValue
let value = rawValue.flatMap(Value.init) ?? wrappedValue
self.init(
value: value, store: store, key: key,
get: { $0 as? Value },
set: { store.setValue($0.rawValue, forKey: key) }
)
}
/// Creates a property that can read and write to an integer user default,
/// transforming that to `RawRepresentable` data type.
///
/// A common usage is with enumerations:
///
/// enum MyEnum: Int {
/// case a
/// case b
/// case c
/// }
///
/// @AppStorage("MyEnumValue") private var value = MyEnum.a
///
/// - Parameters:
/// - wrappedValue: The default value if an integer value
/// is not specified for the given key.
/// - key: The key to read and write the value to in the store
/// store.
/// - store: The store to read and write to. A value
/// of `nil` will use the user default store from the environment.
public init(wrappedValue: Value, _ key: String, store: UserDefaults = .standard) where Value.RawValue == Int {
let rawValue = store.value(forKey: key) as? Value.RawValue
let value = rawValue.flatMap(Value.init) ?? wrappedValue
self.init(
value: value, store: store, key: key,
get: { $0 as? Value },
set: { store.setValue($0.rawValue, forKey: key) }
)
}
}
private final class RefStorage<Value>: NSObject, ObservableObject {
@Published
fileprivate var value: Value
private let defaultValue: Value
private let store: UserDefaults
private let key: String
private let transform: (Any?) -> Value?
deinit {
store.removeObserver(self, forKeyPath: key)
}
init(value: Value, store: UserDefaults, key: String, transform: @escaping (Any?) -> Value?) {
self.value = value
defaultValue = value
self.store = store
self.key = key
self.transform = transform
super.init()
store.addObserver(self, forKeyPath: key, options: .new, context: nil)
}
override func observeValue(
forKeyPath _: String?,
of _: Any?,
change: [NSKeyValueChangeKey: Any]?,
context _: UnsafeMutableRawPointer?
) {
value = change?[.newKey].flatMap(transform) ?? defaultValue
}
}

View File

@ -0,0 +1,225 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 15.0)
extension Backport where Wrapped == Any {
/// Loads and displays an image from the specified URL.
///
/// Until the image loads, SwiftUI displays a default placeholder. When
/// the load operation completes successfully, SwiftUI updates the
/// view to show the loaded image. If the operation fails, SwiftUI
/// continues to display the placeholder. The following example loads
/// and displays an icon from an example server:
///
/// asyncImage(url: URL(string: "https://example.com/icon.png"))
///
/// If you want to customize the placeholder or apply image-specific
/// modifiers --- like ``Image/resizable(capInsets:resizingMode:)`` ---
/// to the loaded image, use the ``init(url:scale:content:placeholder:)``
/// initializer instead.
///
/// - Parameters:
/// - url: The URL of the image to display.
/// - scale: The scale to use for the image. The default is `1`. Set a
/// different value when loading images designed for higher resolution
/// displays. For example, set a value of `2` for an image that you
/// would name with the `@2x` suffix if stored in a file on disk.
@ViewBuilder
public static func asyncImage(url: URL?, scale: CGFloat = 1) -> some View {
_asyncImage(url: url, scale: scale)
}
/// Loads and displays a modifiable image from the specified URL using
/// a custom placeholder until the image loads.
///
/// Until the image loads, SwiftUI displays the placeholder view that
/// you specify. When the load operation completes successfully, SwiftUI
/// updates the view to show content that you specify, which you
/// create using the loaded image. For example, you can show a green
/// placeholder, followed by a tiled version of the loaded image:
///
/// asyncImage(url: URL(string: "https://example.com/icon.png")) { image in
/// image.resizable(resizingMode: .tile)
/// } placeholder: {
/// Color.green
/// }
///
/// If the load operation fails, SwiftUI continues to display the
/// placeholder. To be able to display a different view on a load error,
/// use the ``init(url:scale:transaction:content:)`` initializer instead.
///
/// - Parameters:
/// - url: The URL of the image to display.
/// - scale: The scale to use for the image. The default is `1`. Set a
/// different value when loading images designed for higher resolution
/// displays. For example, set a value of `2` for an image that you
/// would name with the `@2x` suffix if stored in a file on disk.
/// - content: A closure that takes the loaded image as an input, and
/// returns the view to show. You can return the image directly, or
/// modify it as needed before returning it.
/// - placeholder: A closure that returns the view to show until the
/// load operation completes successfully.
@ViewBuilder
public static func asyncImage<I: View, P: View>(
url: URL?, scale: CGFloat = 1, @ViewBuilder content: @escaping (Image) -> I,
@ViewBuilder placeholder: @escaping () -> P
) -> some View {
_asyncImage(url: url, scale: scale, content: content, placeholder: placeholder)
}
/// Loads and displays a modifiable image from the specified URL in phases.
///
/// If you set the asynchronous image's URL to `nil`, or after you set the
/// URL to a value but before the load operation completes, the phase is
/// ``asyncImagePhase/empty``. After the operation completes, the phase
/// becomes either ``asyncImagePhase/failure(_:)`` or
/// ``asyncImagePhase/success(_:)``. In the first case, the phase's
/// ``asyncImagePhase/error`` value indicates the reason for failure.
/// In the second case, the phase's ``asyncImagePhase/image`` property
/// contains the loaded image. Use the phase to drive the output of the
/// `content` closure, which defines the view's appearance:
///
/// asyncImage(url: URL(string: "https://example.com/icon.png")) { phase in
/// if let image = phase.image {
/// image // Displays the loaded image.
/// } else if phase.error != nil {
/// Color.red // Indicates an error.
/// } else {
/// Color.blue // Acts as a placeholder.
/// }
/// }
///
/// To add transitions when you change the URL, apply an identifier to the
/// ``asyncImage``.
///
/// - Parameters:
/// - url: The URL of the image to display.
/// - scale: The scale to use for the image. The default is `1`. Set a
/// different value when loading images designed for higher resolution
/// displays. For example, set a value of `2` for an image that you
/// would name with the `@2x` suffix if stored in a file on disk.
/// - transaction: The transaction to use when the phase changes.
/// - content: A closure that takes the load phase as an input, and
/// returns the view to display for the specified phase.
@ViewBuilder
public static func asyncImage<Content: View>(
url: URL?, scale: CGFloat = 1, transaction: Transaction = Transaction(),
@ViewBuilder content: @escaping (asyncImagePhase) -> Content
) -> some View {
_asyncImage(url: url, scale: scale, transaction: transaction, content: content)
}
/// The current phase of the asynchronous image loading operation.
///
/// When you create an ``asyncImage`` instance with the
/// ``asyncImage/init(url:scale:transaction:content:)`` initializer, you define
/// the appearance of the view using a `content` closure. SwiftUI calls the
/// closure with a phase value at different points during the load operation
/// to indicate the current state. Use the phase to decide what to draw.
/// For example, you can draw the loaded image if it exists, a view that
/// indicates an error, or a placeholder:
///
/// asyncImage(url: URL(string: "https://example.com/icon.png")) { phase in
/// if let image = phase.image {
/// image // Displays the loaded image.
/// } else if phase.error != nil {
/// Color.red // Indicates an error.
/// } else {
/// Color.blue // Acts as a placeholder.
/// }
/// }
public enum asyncImagePhase {
/// No image is loaded.
case empty
/// An image succesfully loaded.
case success(Image)
/// An image failed to load with an error.
case failure(Error)
/// The loaded image, if any.
public var image: Image? {
guard case let .success(image) = self else { return nil }
return image
}
/// The error that occurred when attempting to load an image, if any.
public var error: Error? {
guard case let .failure(error) = self else { return nil }
return error
}
}
// An iOS 13+ async/await backport implementation
private struct _asyncImage<Content: View>: View {
@State private var phase: asyncImagePhase = .empty
var url: URL?
var scale: CGFloat = 1
var transaction: Transaction = .init()
var content: (Backport<Any>.asyncImagePhase) -> Content
public var body: some View {
ZStack {
content(phase)
}
.backport.task(id: url) {
do {
guard !Task.isCancelled, let url = url else { return }
let (data, _) = try await URLSession.shared.backport.data(from: url)
guard !Task.isCancelled else { return }
#if os(macOS)
if let image = NSImage(data: data) {
withTransaction(transaction) {
phase = .success(Image(nsImage: image))
}
}
#else
if let image = UIImage(data: data, scale: scale) {
withTransaction(transaction) {
phase = .success(Image(uiImage: image))
}
}
#endif
} catch {
phase = .failure(error)
}
}
}
init(url: URL?, scale: CGFloat = 1) where Content == AnyView {
self.url = url
self.scale = scale
content = { AnyView($0.image) }
}
init<I, P>(
url: URL?, scale: CGFloat = 1, @ViewBuilder content: @escaping (Image) -> I,
@ViewBuilder placeholder: @escaping () -> P
) where Content == _ConditionalContent<I, P> {
self.url = url
self.scale = scale
transaction = Transaction()
self.content = { phase -> _ConditionalContent<I, P> in
if let image = phase.image {
return ViewBuilder.buildEither(first: content(image))
} else {
return ViewBuilder.buildEither(second: placeholder())
}
}
}
init(
url: URL?, scale: CGFloat = 1, transaction: Transaction = Transaction(),
@ViewBuilder content: @escaping (Backport<Any>.asyncImagePhase) -> Content
) {
self.url = url
self.scale = scale
self.transaction = transaction
self.content = content
}
}
}

View File

@ -0,0 +1,135 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
extension Backport where Wrapped: View {
/// Layers the views that you specify behind this view.
///
/// Use this modifier to place one or more views behind another view.
/// For example, you can place a collection of stars beind a ``Text`` view:
///
/// Text("ABCDEF")
/// .background(alignment: .leading) { Star(color: .red) }
/// .background(alignment: .center) { Star(color: .green) }
/// .background(alignment: .trailing) { Star(color: .blue) }
///
/// The example above assumes that you've defined a `Star` view with a
/// parameterized color:
///
/// struct Star: View {
/// var color: Color
///
/// var body: some View {
/// Image(systemName: "star.fill")
/// .foregroundStyle(color)
/// }
/// }
///
/// By setting different `alignment` values for each modifier, you make the
/// stars appear in different places behind the text:
///
/// ![A screenshot of the letters A, B, C, D, E, and F written in front of
/// three stars. The stars, from left to right, are red, green, and
/// blue.](View-background-2)
///
/// If you specify more than one view in the `content` closure, the modifier
/// collects all of the views in the closure into an implicit ``ZStack``,
/// taking them in order from back to front. For example, you can layer a
/// vertical bar behind a circle, with both of those behind a horizontal
/// bar:
///
/// Color.blue
/// .frame(width: 200, height: 10) // Creates a horizontal bar.
/// .background {
/// Color.green
/// .frame(width: 10, height: 100) // Creates a vertical bar.
/// Circle()
/// .frame(width: 50, height: 50)
/// }
///
/// Both the background modifier and the implicit ``ZStack`` composed from
/// the background content --- the circle and the vertical bar --- use a
/// default ``Alignment/center`` alignment. The vertical bar appears
/// centered behind the circle, and both appear as a composite view centered
/// behind the horizontal bar:
///
/// ![A screenshot of a circle with a horizontal blue bar layered on top
/// and a vertical green bar layered underneath. All of the items are center
/// aligned.](View-background-3)
///
/// If you specify an alignment for the background, it applies to the
/// implicit stack rather than to the individual views in the closure. You
/// can see this if you add the ``Alignment/leading`` alignment:
///
/// Color.blue
/// .frame(width: 200, height: 10)
/// .background(alignment: .leading) {
/// Color.green
/// .frame(width: 10, height: 100)
/// Circle()
/// .frame(width: 50, height: 50)
/// }
///
/// The vertical bar and the circle move as a unit to align the stack
/// with the leading edge of the horizontal bar, while the
/// vertical bar remains centered on the circle:
///
/// ![A screenshot of a horizontal blue bar in front of a circle, which
/// is in front of a vertical green bar. The horizontal bar and the circle
/// are center aligned with each other; the left edges of the circle
/// and the horizontal are aligned.](View-background-3a)
///
/// To control the placement of individual items inside the `content`
/// closure, either use a different background modifier for each item, as
/// the earlier example of stars under text demonstrates, or add an explicit
/// ``ZStack`` inside the content closure with its own alignment:
///
/// Color.blue
/// .frame(width: 200, height: 10)
/// .background(alignment: .leading) {
/// ZStack(alignment: .leading) {
/// Color.green
/// .frame(width: 10, height: 100)
/// Circle()
/// .frame(width: 50, height: 50)
/// }
/// }
///
/// The stack alignment ensures that the circle's leading edge aligns with
/// the vertical bar's, while the background modifier aligns the composite
/// view with the horizontal bar:
///
/// ![A screenshot of a horizontal blue bar in front of a circle, which
/// is in front of a vertical green bar. All items are aligned on their
/// left edges.](View-background-4)
///
/// You can achieve layering without a background modifier by putting both
/// the modified view and the background content into a ``ZStack``. This
/// produces a simpler view hierarchy, but it changes the layout priority
/// that SwiftUI applies to the views. Use the background modifier when you
/// want the modified view to dominate the layout.
///
/// If you want to specify a ``ShapeStyle`` like a
/// ``HierarchicalShapeStyle`` or a ``Material`` as the background, use
/// ``View/background(_:ignoresSafeAreaEdges:)`` instead.
/// To specify a ``Shape`` or ``InsettableShape``, use
/// ``View/background(_:in:fillStyle:)-89n7j`` or
/// ``View/background(_:in:fillStyle:)-20tq5``, respectively.
///
/// - Parameters:
/// - alignment: The alignment that the modifier uses to position the
/// implicit ``ZStack`` that groups the background views. The default
/// is ``Alignment/center``.
/// - content: A ``ViewBuilder`` that you use to declare the views to draw
/// behind this view, stacked in a cascading order from bottom to top.
/// The last view that you list appears at the front of the stack.
///
/// - Returns: A view that uses the specified content as a background.
public func background<Content: View>(alignment: Alignment = .center, @ViewBuilder _ content: () -> Content)
-> some View
{
self.content.background(content(), alignment: alignment)
}
}

View File

@ -0,0 +1,188 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
extension EnvironmentValues {
/// An action that dismisses the current presentation.
///
/// Use this environment value to get the ``Backport.DismissAction`` instance
/// for the current ``Environment``. Then call the instance
/// to perform the dismissal. You call the instance directly because
/// it defines a ``Backport.DismissAction/callAsFunction()``
/// method that Swift calls when you call the instance.
///
/// For example, you can create a button that calls the ``Backport.DismissAction``:
///
/// private struct SheetContents: View {
/// @Environment(\.backportDismiss) private var dismiss
///
/// var body: some View {
/// Button("Done") {
/// dismiss()
/// }
/// }
/// }
///
/// If you present the `SheetContents` view in a sheet, the user can dismiss
/// the sheet by tapping or clicking the sheet's button:
///
/// private struct DetailView: View {
/// @State private var isSheetPresented = false
///
/// var body: some View {
/// Button("Show Sheet") {
/// isSheetPresented = true
/// }
/// .sheet(isPresented: $isSheetPresented) {
/// SheetContents()
/// }
/// }
/// }
///
/// Be sure that you define the action in the appropriate environment.
/// For example, don't reorganize the `DetailView` in the example above
/// so that it creates the `dismiss` property and calls it from the
/// ``View/sheet(item:onDismiss:content:)`` view modifier's `content`
/// closure:
///
/// private struct DetailView: View {
/// @State private var isSheetPresented = false
/// @Environment(\.backportDismiss) private var dismiss // Applies to DetailView.
///
/// var body: some View {
/// Button("Show Sheet") {
/// isSheetPresented = true
/// }
/// .sheet(isPresented: $isSheetPresented) {
/// Button("Done") {
/// dismiss() // Fails to dismiss the sheet.
/// }
/// }
/// }
/// }
///
/// If you do this, the sheet fails to dismiss because the action applies
/// to the environment where you declared it, which is that of the detail
/// view, rather than the sheet. In fact, if you've presented the detail
/// view in a ``NavigationView``, the dismissal pops the detail view
/// the navigation stack.
///
/// The dismiss action has no effect on a view that isn't currently
/// presented. If you need to query whether SwiftUI is currently presenting
/// a view, read the ``EnvironmentValues/backportIsPresented`` environment value.
public var backportDismiss: Backport<Any>.DismissAction {
.init(presentation: presentationMode)
}
/// A Boolean value that indicates whether the view associated with this
/// environment is currently presented.
///
/// You can read this value like any of the other ``EnvironmentValues``
/// by creating a property with the ``Environment`` property wrapper:
///
/// @Environment(\.backportIsPresented) private var isPresented
///
/// Read the value inside a view if you need to know when SwiftUI
/// presents that view. For example, you can take an action when SwiftUI
/// presents a view by using the ``View/onChange(of:perform:)``
/// modifier:
///
/// .onChange(of: isPresented) { isPresented in
/// if isPresented {
/// // Do something when first presented.
/// }
/// }
///
/// This behaves differently than ``View/onAppear(perform:)``, which
/// SwiftUI can call more than once for a given presentation, like
/// when you navigate back to a view that's already in the
/// navigation hierarchy.
///
/// To dismiss the currently presented view, use
/// ``EnvironmentValues/backportDismiss``.
public var backportIsPresented: Bool {
presentationMode.wrappedValue.isPresented
}
}
@available(iOS, deprecated: 15)
@available(macOS, deprecated: 12)
@available(tvOS, deprecated: 15)
@available(watchOS, deprecated: 8)
extension Backport where Wrapped: Any {
/// An action that dismisses a presentation.
///
/// Use the ``EnvironmentValues/dismiss`` environment value to get the instance
/// of this structure for a given ``Environment``. Then call the instance
/// to perform the dismissal. You call the instance directly because
/// it defines a ``DismissAction/callAsFunction()``
/// method that Swift calls when you call the instance.
///
/// For example, you can create a button that calls the ``DismissAction``:
///
/// private struct SheetContents: View {
/// @Environment(\.backportDismiss) private var dismiss
///
/// var body: some View {
/// Button("Done") {
/// dismiss()
/// }
/// }
/// }
///
/// If you present the `SheetContents` view in a sheet, the user can dismiss
/// the sheet by tapping or clicking the sheet's button:
///
/// private struct DetailView: View {
/// @State private var isSheetPresented = false
///
/// var body: some View {
/// Button("Show Sheet") {
/// isSheetPresented = true
/// }
/// .sheet(isPresented: $isSheetPresented) {
/// SheetContents()
/// }
/// }
/// }
///
/// Be sure that you define the action in the appropriate environment.
/// For example, don't reorganize the `DetailView` in the example above
/// so that it creates the `dismiss` property and calls it from the
/// ``View/sheet(item:onDismiss:content:)`` view modifier's `content`
/// closure:
///
/// private struct DetailView: View {
/// @State private var isSheetPresented = false
/// @Environment(\.backportDismiss) private var dismiss // Applies to DetailView.
///
/// var body: some View {
/// Button("Show Sheet") {
/// isSheetPresented = true
/// }
/// .sheet(isPresented: $isSheetPresented) {
/// Button("Done") {
/// dismiss() // Fails to dismiss the sheet.
/// }
/// }
/// }
/// }
///
/// If you do this, the sheet fails to dismiss because the action applies
/// to the environment where you declared it, which is that of the detail
/// view, rather than the sheet. In fact, if you've presented the detail
/// view in a ``NavigationView``, the dismissal pops the detail view
/// from the navigation stack.
///
/// The dismiss action has no effect on a view that isn't currently
/// presented. If you need to query whether SwiftUI is currently presenting
/// a view, read the ``EnvironmentValues/backportIsPresented`` environment value.
public struct DismissAction {
var presentation: Binding<PresentationMode>
public func callAsFunction() {
presentation.wrappedValue.dismiss()
}
}
}

View File

@ -0,0 +1,60 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
private struct BackportDynamicTypeKey: EnvironmentKey {
static var defaultValue: Backport.DynamicTypeSize = .large
}
@available(iOS, deprecated: 15)
@available(tvOS, deprecated: 15)
@available(macOS, deprecated: 12)
@available(watchOS, deprecated: 8)
extension EnvironmentValues {
/// Sets the Dynamic Type size within the view to the given value.
///
/// As an example, you can set a Dynamic Type size in `ContentView` to be
/// ``DynamicTypeSize/xLarge`` (this can be useful in previews to see your
/// content at a different size) like this:
///
/// ContentView()
/// .backport.dynamicTypeSize(.xLarge)
///
/// If a Dynamic Type size range is applied after setting a value,
/// the value is limited by that range:
///
/// ContentView() // Dynamic Type size will be .large
/// .backport.dynamicTypeSize(...DynamicTypeSize.large)
/// .backport.dynamicTypeSize(.xLarge)
///
/// When limiting the Dynamic Type size, consider if adding a
/// large content view with ``View/accessibilityShowsLargeContentViewer()``
/// would be appropriate.
///
/// - Parameter size: The size to set for this view.
///
/// - Returns: A view that sets the Dynamic Type size to the specified
/// `size`.
public var backportDynamicTypeSize: Backport<Any>.DynamicTypeSize {
get { .init(self[keyPath: \.sizeCategory]) }
set { self[keyPath: \.sizeCategory] = newValue.sizeCategory }
}
}
private struct DynamicTypeRangeKey: EnvironmentKey {
static var defaultValue: Range<Backport<Any>.DynamicTypeSize> {
.init(uncheckedBounds: (lower: .xSmall, upper: .accessibility5))
}
}
extension EnvironmentValues {
var dynamicTypeRange: Range<Backport<Any>.DynamicTypeSize> {
get { self[DynamicTypeRangeKey.self] }
set {
let current = self[DynamicTypeRangeKey.self]
self[DynamicTypeRangeKey.self] = current.clamped(to: newValue)
}
}
}

View File

@ -0,0 +1,110 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
extension Backport where Wrapped: View {
/// Sets the Dynamic Type size within the view to the given value.
///
/// As an example, you can set a Dynamic Type size in `ContentView` to be
/// ``DynamicTypeSize/xLarge`` (this can be useful in previews to see your
/// content at a different size) like this:
///
/// ContentView()
/// .dynamicTypeSize(.xLarge)
///
/// If a Dynamic Type size range is applied after setting a value,
/// the value is limited by that range:
///
/// ContentView() // Dynamic Type size will be .large
/// .dynamicTypeSize(...DynamicTypeSize.large)
/// .dynamicTypeSize(.xLarge)
///
/// When limiting the Dynamic Type size, consider if adding a
/// large content view with ``View/accessibilityShowsLargeContentViewer()``
/// would be appropriate.
///
/// - Parameter size: The size to set for this view.
///
/// - Returns: A view that sets the Dynamic Type size to the specified
/// `size`.
@ViewBuilder
public func dynamicTypeSize(_ size: Backport<Any>.DynamicTypeSize) -> some View {
content.environment(\.backportDynamicTypeSize, size)
}
/// Limits the Dynamic Type size within the view to the given range.
///
/// As an example, you can constrain the maximum Dynamic Type size in
/// `ContentView` to be no larger than ``DynamicTypeSize/large``:
///
/// ContentView()
/// .dynamicTypeSize(...DynamicTypeSize.large)
///
/// If the Dynamic Type size is limited to multiple ranges, the result is
/// their intersection:
///
/// ContentView() // Dynamic Type sizes are from .small to .large
/// .dynamicTypeSize(.small...)
/// .dynamicTypeSize(...DynamicTypeSize.large)
///
/// A specific Dynamic Type size can still be set after a range is applied:
///
/// ContentView() // Dynamic Type size is .xLarge
/// .dynamicTypeSize(.xLarge)
/// .dynamicTypeSize(...DynamicTypeSize.large)
///
/// When limiting the Dynamic Type size, consider if adding a
/// large content view with ``View/accessibilityShowsLargeContentViewer()``
/// would be appropriate.
///
/// - Parameter range: The range of sizes that are allowed in this view.
///
/// - Returns: A view that constrains the Dynamic Type size of this view
/// within the specified `range`.
@ViewBuilder
public func dynamicTypeSize<T>(_ range: T) -> some View
where T: RangeExpression, T.Bound == Backport<Any>.DynamicTypeSize {
if let range = range as? Range<T.Bound> {
content
.modifier(DynamicTypeRangeModifier())
.environment(\.dynamicTypeRange, range)
} else if let range = range as? ClosedRange<T.Bound> {
content
.modifier(DynamicTypeRangeModifier())
.environment(\.dynamicTypeRange, .init(uncheckedBounds: (lower: range.lowerBound, upper: range.upperBound)))
} else if let range = range as? PartialRangeFrom<T.Bound> {
content
.modifier(DynamicTypeRangeModifier())
.environment(\.dynamicTypeRange, .init(uncheckedBounds: (range.lowerBound, .accessibility5)))
} else if let range = range as? PartialRangeUpTo<T.Bound> {
content
.modifier(DynamicTypeRangeModifier())
.environment(\.dynamicTypeRange, .init(uncheckedBounds: (.xSmall, range.upperBound)))
} else if let range = range as? PartialRangeThrough<T.Bound> {
content
.modifier(DynamicTypeRangeModifier())
.environment(\.dynamicTypeRange, .init(uncheckedBounds: (.xSmall, range.upperBound)))
} else {
content
.modifier(DynamicTypeRangeModifier())
}
}
}
private struct DynamicTypeRangeModifier: ViewModifier {
@Environment(\.dynamicTypeRange) private var range
@Environment(\.backportDynamicTypeSize) private var size
private var resolvedSize: Backport<Any>.DynamicTypeSize {
print(range)
return range.contains(size)
? size
: max(range.lowerBound, min(range.upperBound, size))
}
func body(content: Content) -> some View {
content.environment(\.backportDynamicTypeSize, resolvedSize)
}
}

View File

@ -0,0 +1,224 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 15)
@available(tvOS, deprecated: 15)
@available(macOS, deprecated: 12)
@available(watchOS, deprecated: 8)
extension Backport where Wrapped == Any {
/// A Dynamic Type size, which specifies how large scalable content should be.
///
/// For more information about Dynamic Type sizes in iOS, see iOS Human Interface Guidelines >
/// [Dynamic Type Sizes](https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/typography/#dynamic-type-sizes).
/// For more information about Dynamic Type sizes in watchOS, see watchOS Human Interface Guidelines >
/// [Dynamic Type Sizes](https://developer.apple.com/design/human-interface-guidelines/watchos/visual/typography/#dynamic-type-sizes).
public enum DynamicTypeSize: Hashable, Comparable, CaseIterable {
/// An extra small size.
case xSmall
/// A small size.
case small
/// A medium size.
case medium
/// A large size.
case large
/// An extra large size.
case xLarge
/// An extra extra large size.
case xxLarge
/// An extra extra extra large size.
case xxxLarge
/// The first accessibility size.
case accessibility1
/// The second accessibility size.
case accessibility2
/// The third accessibility size.
case accessibility3
/// The fourth accessibility size.
case accessibility4
/// The fifth accessibility size.
case accessibility5
/// A Boolean value indicating whether the size is one that is associated
/// with accessibility.
public var isAccessibilitySize: Bool {
self >= .accessibility1
}
#if os(iOS) || os(tvOS)
/// Create a Dynamic Type size from its `UIContentSizeCategory` equivalent.
public init?(_ uiSizeCategory: UIContentSizeCategory) {
switch uiSizeCategory {
case .extraSmall:
self = .xSmall
case .small:
self = .small
case .medium:
self = .medium
case .large:
self = .medium
case .extraLarge:
self = .xLarge
case .extraExtraLarge:
self = .xxLarge
case .extraExtraExtraLarge:
self = .xxxLarge
case .accessibilityMedium:
self = .accessibility1
case .accessibilityLarge:
self = .accessibility2
case .accessibilityExtraLarge:
self = .accessibility3
case .accessibilityExtraExtraLarge:
self = .accessibility4
case .accessibilityExtraExtraExtraLarge:
self = .accessibility5
default:
return nil
}
}
#endif
internal init(_ sizeCategory: ContentSizeCategory) {
switch sizeCategory {
case .extraSmall:
self = .xSmall
case .small:
self = .small
case .medium:
self = .medium
case .large:
self = .large
case .extraLarge:
self = .xLarge
case .extraExtraLarge:
self = .xxLarge
case .extraExtraExtraLarge:
self = .xxxLarge
case .accessibilityMedium:
self = .accessibility1
case .accessibilityLarge:
self = .accessibility2
case .accessibilityExtraLarge:
self = .accessibility3
case .accessibilityExtraExtraLarge:
self = .accessibility4
case .accessibilityExtraExtraExtraLarge:
self = .accessibility5
default:
self = .large
}
}
var sizeCategory: ContentSizeCategory {
switch self {
case .xSmall:
return .extraSmall
case .small:
return .small
case .medium:
return .medium
case .large:
return .large
case .xLarge:
return .extraLarge
case .xxLarge:
return .extraExtraLarge
case .xxxLarge:
return .extraExtraExtraLarge
case .accessibility1:
return .accessibilityMedium
case .accessibility2:
return .accessibilityLarge
case .accessibility3:
return .accessibilityExtraLarge
case .accessibility4:
return .accessibilityExtraExtraLarge
case .accessibility5:
return .accessibilityExtraExtraExtraLarge
}
}
}
}
@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *)
extension Backport.DynamicTypeSize {
var dynamicTypeSize: DynamicTypeSize {
switch self {
case .xSmall:
return .xSmall
case .small:
return .small
case .medium:
return .medium
case .large:
return .large
case .xLarge:
return .xLarge
case .xxLarge:
return .xxLarge
case .xxxLarge:
return .xxxLarge
case .accessibility1:
return .accessibility1
case .accessibility2:
return .accessibility2
case .accessibility3:
return .accessibility3
case .accessibility4:
return .accessibility4
case .accessibility5:
return .accessibility5
}
}
}
#if os(iOS) || os(tvOS)
@available(iOS, deprecated: 15)
@available(tvOS, deprecated: 15)
extension UIContentSizeCategory {
public init(_ dynamicTypeSize: Backport<Any>.DynamicTypeSize?) {
switch dynamicTypeSize {
case .xSmall:
self = .extraSmall
case .small:
self = .small
case .medium:
self = .medium
case .large:
self = .large
case .xLarge:
self = .extraLarge
case .xxLarge:
self = .extraExtraLarge
case .xxxLarge:
self = .extraExtraExtraLarge
case .accessibility1:
self = .accessibilityMedium
case .accessibility2:
self = .accessibilityLarge
case .accessibility3:
self = .accessibilityExtraLarge
case .accessibility4:
self = .accessibilityExtraExtraLarge
case .accessibility5:
self = .accessibilityExtraExtraExtraLarge
case .none:
self = .large
}
}
}
#endif

View File

@ -0,0 +1,180 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14)
@available(watchOS, deprecated: 7)
extension Backport where Wrapped == Any {
/// A standard label for user interface items, consisting of an icon with a
/// title.
///
/// One of the most common and recognizable user interface components is the
/// combination of an icon and a label. This idiom appears across many kinds of
/// apps and shows up in collections, lists, menus of action items, and
/// disclosable lists, just to name a few.
///
/// You create a label, in its simplest form, by providing a title and the name
/// of an image, such as an icon from the
/// [SF Symbols](https://developer.apple.com/design/human-interface-guidelines/sf-symbols/overview/)
/// collection:
///
/// Label("Lightning", systemImage: "bolt.fill")
///
/// You can also apply styles to labels in several ways. In the case of dynamic
/// changes to the view after device rotation or change to a window size you
/// might want to show only the text portion of the label using the
/// ``LabelStyle/titleOnly`` label style:
///
/// Label("Lightning", systemImage: "bolt.fill")
/// .labelStyle(.titleOnly)
///
/// Conversely, there's also an icon-only label style:
///
/// Label("Lightning", systemImage: "bolt.fill")
/// .labelStyle(.iconOnly)
///
/// Some containers might apply a different default label style, such as only
/// showing icons within toolbars on macOS and iOS. To opt in to showing both
/// the title and the icon, you can apply the ``LabelStyle/titleAndIcon`` label
/// style:
///
/// Label("Lightning", systemImage: "bolt.fill")
/// .labelStyle(.titleAndIcon)
///
/// You can also create a customized label style by modifying an existing
/// style; this example adds a red border to the default label style:
///
/// struct RedBorderedLabelStyle: LabelStyle {
/// func makeBody(configuration: Configuration) -> some View {
/// Label(configuration)
/// .border(Color.red)
/// }
/// }
///
/// For more extensive customization or to create a completely new label style,
/// you'll need to adopt the ``LabelStyle`` protocol and implement a
/// ``LabelStyleConfiguration`` for the new style.
///
/// To apply a common label style to a group of labels, apply the style
/// to the view hierarchy that contains the labels:
///
/// VStack {
/// Label("Rain", systemImage: "cloud.rain")
/// Label("Snow", systemImage: "snow")
/// Label("Sun", systemImage: "sun.max")
/// }
/// .labelStyle(.iconOnly)
///
/// It's also possible to make labels using views to compose the label's icon
/// programmatically, rather than using a pre-made image. In this example, the
/// icon portion of the label uses a filled ``Circle`` overlaid
/// with the user's initials:
///
/// Label {
/// Text(person.fullName)
/// .font(.body)
/// .foregroundColor(.primary)
/// Text(person.title)
/// .font(.subheadline)
/// .foregroundColor(.secondary)
/// } icon: {
/// Circle()
/// .fill(person.profileColor)
/// .frame(width: 44, height: 44, alignment: .center)
/// .overlay(Text(person.initials))
/// }
///
public struct Label<Title, Icon>: View where Title: View, Icon: View {
@Environment(\.self) private var environment
@Environment(\.backportLabelStyle) private var style
private var config: Backport<Any>.LabelStyleConfiguration
/// Creates a label with a custom title and icon.
public init(@ViewBuilder title: () -> Title, @ViewBuilder icon: () -> Icon) {
config = .init(title: .init(content: title()), icon: .init(content: icon()))
}
@MainActor public var body: some View {
if let style = style {
style.makeBody(configuration: config.environment(environment))
} else {
DefaultLabelStyle()
.makeBody(configuration: config.environment(environment))
}
}
}
}
extension Backport.Label where Wrapped == Any, Title == Text, Icon == Image {
/// Creates a label with an icon image and a title generated from a
/// localized string.
///
/// - Parameters:
/// - titleKey: A title generated from a localized string.
/// - image: The name of the image resource to lookup.
public init(_ titleKey: LocalizedStringKey, image name: String) {
self.init(title: { Text(titleKey) }, icon: { Image(name) })
}
/// Creates a label with an icon image and a title generated from a string.
///
/// - Parameters:
/// - title: A string used as the label's title.
/// - image: The name of the image resource to lookup.
public init<S>(_ title: S, image name: String) where S: StringProtocol {
self.init(title: { Text(title) }, icon: { Image(name) })
}
}
@available(macOS, introduced: 11, message: "SFSymbols support was only introduced in macOS 11")
extension Backport.Label where Wrapped == Any, Title == Text, Icon == Image {
/// Creates a label with a system icon image and a title generated from a
/// localized string.
///
/// - Parameters:
/// - titleKey: A title generated from a localized string.
/// - systemImage: The name of the image resource to lookup.
public init(_ titleKey: LocalizedStringKey, systemImage name: String) {
self.init(title: { Text(titleKey) }, icon: { Image(systemName: name) })
}
/// Creates a label with a system icon image and a title generated from a
/// string.
///
/// - Parameters:
/// - title: A string used as the label's title.
/// - systemImage: The name of the image resource to lookup.
public init<S>(_ title: S, systemImage name: String) where S: StringProtocol {
self.init(title: { Text(title) }, icon: { Image(systemName: name) })
}
}
extension Backport.Label
where Wrapped == Any, Title == Backport.LabelStyleConfiguration.Title, Icon == Backport.LabelStyleConfiguration.Icon {
/// Creates a label representing the configuration of a style.
///
/// You can use this initializer within the ``LabelStyle/makeBody(configuration:)``
/// method of a ``LabelStyle`` instance to create an instance of the label
/// that's being styled. This is useful for custom label styles that only
/// wish to modify the current style, as opposed to implementing a brand new
/// style.
///
/// For example, the following style adds a red border around the label,
/// but otherwise preserves the current style:
///
/// struct RedBorderedLabelStyle: LabelStyle {
/// func makeBody(configuration: Configuration) -> some View {
/// Label(configuration)
/// .border(Color.red)
/// }
/// }
///
/// - Parameter configuration: The label style to use.
public init(_ configuration: Backport.LabelStyleConfiguration) {
config = configuration
}
}

View File

@ -0,0 +1,46 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14)
@available(watchOS, deprecated: 7)
extension Backport where Wrapped == Any {
/// The properties of a label.
public struct LabelStyleConfiguration {
/// A type-erased title view of a label.
public struct Title: View {
let content: AnyView
public var body: some View { content }
init<Content: View>(content: Content) {
self.content = .init(content)
}
}
/// A type-erased icon view of a label.
public struct Icon: View {
let content: AnyView
public var body: some View { content }
init<Content: View>(content: Content) {
self.content = .init(content)
}
}
/// A description of the labeled item.
public internal(set) var title: LabelStyleConfiguration.Title
/// A symbolic representation of the labeled item.
public internal(set) var icon: LabelStyleConfiguration.Icon
internal var environment: EnvironmentValues = .init()
func environment(_ values: EnvironmentValues) -> Self {
var config = self
config.environment = values
return config
}
}
}

View File

@ -0,0 +1,64 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14)
@available(watchOS, deprecated: 7)
/// A type that applies a custom appearance to all labels within a view.
///
/// To configure the current label style for a view hierarchy, use the
/// ``View/labelStyle(_:)`` modifier.
public protocol BackportLabelStyle {
/// The properties of a label.
typealias Configuration = Backport<Any>.LabelStyleConfiguration
/// A view that represents the body of a label.
associatedtype Body: View
/// Creates a view that represents the body of a label.
///
/// The system calls this method for each ``Label`` instance in a view
/// hierarchy where this style is the current label style.
///
/// - Parameter configuration: The properties of the label.
@ViewBuilder func makeBody(configuration: Configuration) -> Body
}
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14.0)
@available(watchOS, deprecated: 7.0)
extension Backport where Wrapped: View {
public func labelStyle<S: BackportLabelStyle>(_ style: S) -> some View {
content.environment(\.backportLabelStyle, .init(style))
}
}
internal struct AnyLabelStyle: BackportLabelStyle {
let _makeBody: (Backport<Any>.LabelStyleConfiguration) -> AnyView
init<S: BackportLabelStyle>(_ style: S) {
_makeBody = { config in
AnyView(style.makeBody(configuration: config))
}
}
func makeBody(configuration: Configuration) -> some View {
_makeBody(configuration)
}
}
private struct BackportLabelStyleEnvironmentKey: EnvironmentKey {
static var defaultValue: AnyLabelStyle?
}
extension EnvironmentValues {
var backportLabelStyle: AnyLabelStyle? {
get { self[BackportLabelStyleEnvironmentKey.self] }
set { self[BackportLabelStyleEnvironmentKey.self] = newValue }
}
}

View File

@ -0,0 +1,41 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14)
@available(watchOS, deprecated: 7)
extension Backport where Wrapped == Any {
/// The default label style in the current context.
///
/// You can also use ``LabelStyle/automatic`` to construct this style.
public struct DefaultLabelStyle: BackportLabelStyle {
public init() {}
/// Creates a view that represents the body of a label.
///
/// The system calls this method for each ``Label`` instance in a view
/// hierarchy where this style is the current label style.
///
/// - Parameter configuration: The properties of the label.
public func makeBody(configuration: DefaultLabelStyle.Configuration) -> some View {
HStack {
configuration.icon
configuration.title
}
}
}
}
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14)
@available(watchOS, deprecated: 7)
extension BackportLabelStyle where Self == Backport<Any>.DefaultLabelStyle {
/// A label style that resolves its appearance automatically based on the
/// current context.
public static var automatic: Self { .init() }
}

View File

@ -0,0 +1,41 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14)
@available(watchOS, deprecated: 7)
extension Backport where Wrapped == Any {
/// A label style that only displays the icon of the label.
///
/// You can also use ``LabelStyle/iconOnly`` to construct this style.
public struct IconOnlyLabelStyle: BackportLabelStyle {
/// Creates an icon-only label style.
public init() {}
/// Creates a view that represents the body of a label.
///
/// The system calls this method for each ``Label`` instance in a view
/// hierarchy where this style is the current label style.
///
/// - Parameter configuration: The properties of the label.
public func makeBody(configuration: Configuration) -> some View {
configuration.icon
}
}
}
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14)
@available(watchOS, deprecated: 7)
extension BackportLabelStyle where Self == Backport<Any>.IconOnlyLabelStyle {
/// A label style that only displays the icon of the label.
///
/// The title of the label is still used for non-visual descriptions, such as
/// VoiceOver.
public static var iconOnly: Self { .init() }
}

View File

@ -0,0 +1,67 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14)
@available(watchOS, deprecated: 7)
extension Backport where Wrapped == Any {
/// A label style that shows both the title and icon of the label using a
/// system-standard layout.
///
/// You can also use ``LabelStyle/titleAndIcon`` to construct this style.
public struct TitleAndIconLabelStyle: BackportLabelStyle {
/// Creates a label style that shows both the title and icon of the label
/// using a system-standard layout.
public init() {}
/// Creates a view that represents the body of a label.
///
/// The system calls this method for each ``Label`` instance in a view
/// hierarchy where this style is the current label style.
///
/// - Parameter configuration: The properties of the label.
public func makeBody(configuration: Configuration) -> some View {
HStack {
configuration.icon
configuration.title
}
}
}
}
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14)
@available(watchOS, deprecated: 7)
extension BackportLabelStyle where Self == Backport<Any>.TitleAndIconLabelStyle {
/// A label style that shows both the title and icon of the label using a
/// system-standard layout.
///
/// In most cases, labels show both their title and icon by default. However,
/// some containers might apply a different default label style to their
/// content, such as only showing icons within toolbars on macOS and iOS. To
/// opt in to showing both the title and the icon, you can apply the title
/// and icon label style:
///
/// Label("Lightning", systemImage: "bolt.fill")
/// .labelStyle(.titleAndIcon)
///
/// To apply the title and icon style to a group of labels, apply the style
/// to the view hierarchy that contains the labels:
///
/// VStack {
/// Label("Rain", systemImage: "cloud.rain")
/// Label("Snow", systemImage: "snow")
/// Label("Sun", systemImage: "sun.max")
/// }
/// .labelStyle(.titleAndIcon)
///
/// The relative layout of the title and icon is dependent on the context it
/// is displayed in. In most cases, however, the label is arranged
/// horizontally with the icon leading.
public static var titleAndIcon: Self { .init() }
}

View File

@ -0,0 +1,38 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14)
@available(watchOS, deprecated: 7)
extension Backport where Wrapped == Any {
// A label style that only displays the title of the label.
///
/// You can also use ``LabelStyle/titleOnly`` to construct this style.
public struct TitleOnlyLabelStyle: BackportLabelStyle {
/// Creates a title-only label style.
public init() {}
/// Creates a view that represents the body of a label.
///
/// The system calls this method for each ``Label`` instance in a view
/// hierarchy where this style is the current label style.
///
/// - Parameter configuration: The properties of the label.
public func makeBody(configuration: Configuration) -> some View {
configuration.title
}
}
}
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14)
@available(watchOS, deprecated: 7)
extension BackportLabelStyle where Self == Backport<Any>.TitleOnlyLabelStyle {
/// A label style that only displays the title of the label.
public static var titleOnly: Self { .init() }
}

View File

@ -0,0 +1,268 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 16)
@available(tvOS, deprecated: 16)
@available(macOS, deprecated: 13)
@available(watchOS, deprecated: 9)
extension Backport where Wrapped == Any {
/// A container for attaching a label to a value-bearing view.
///
/// The instance's content represents a read-only or read-write value, and its
/// label identifies or describes the purpose of that value.
/// The resulting element has a layout that's consistent with other framework
/// controls and automatically adapts to its container, like a form or toolbar.
/// Some styles of labeled content also apply styling or behaviors to the value
/// content, like making ``Text`` views selectable.
///
/// The following example associates a label with a custom view and has
/// a layout that matches the label of the ``Picker``:
///
/// Form {
/// Backport.LabeledContent("Custom Value") {
/// MyCustomView(value: $value)
/// }
/// Picker("Selected Value", selection: $selection) {
/// PickerOption("Option 1", 1)
/// PickerOption("Option 2", 2)
/// }
/// }
///
/// ### Custom view labels
///
/// You can assemble labeled content with an explicit view for its label
/// using the ``init(content:label:)`` initializer. For example, you can
/// rewrite the previous labeled content example using a ``Text`` view:
///
/// LabeledContent {
/// MyCustomView(value: $value)
/// } label: {
/// Text("Custom Value")
/// }
///
/// The `label` view builder accepts any kind of view, like a ``Label``:
///
/// Backport.LabeledContent {
/// MyCustomView(value: $value)
/// } label: {
/// Label("Custom Value", systemImage: "hammer")
/// }
///
/// ### Textual labeled content
///
/// You can construct labeled content with string values or formatted values
/// to create read-only displays of textual values:
///
/// Form {
/// Section("Information") {
/// Backport.LabeledContent("Name", value: person.name)
/// }
/// if !person.pets.isEmpty {
/// Section("Pets") {
/// ForEach(pet) { pet in
/// Backport.LabeledContent(pet.species, value: pet.name)
/// }
/// }
/// }
/// }
///
/// Wherever possible, SwiftUI makes this text selectable.
///
/// ### Compositional elements
///
/// You can use labeled content as the label for other elements. For example,
/// a ``NavigationLink`` can present a summary value for the destination it
/// links to:
///
/// Form {
/// NavigationLink(value: Settings.wifiDetail) {
/// Backport.LabeledContent("Wi-Fi", value: ssidName)
/// }
/// }
///
/// In some cases, the styling of views used as the value content is
/// specialized as well. For example, while a ``Toggle`` in an inset group
/// form on macOS is styled as a switch by default, it's styled as a checkbox
/// when used as a value element within a surrounding `LabeledContent`
/// instance:
///
/// Form {
/// Backport.LabeledContent("Source Control") {
/// Toggle("Refresh local status automatically",
/// isOn: $refreshLocalStatus)
/// Toggle("Fetch and refresh server status automatically",
/// isOn: $refreshServerStatus)
/// Toggle("Add and remove files automatically",
/// isOn: $addAndRemoveFiles)
/// Toggle("Select files to commit automatically",
/// isOn: $selectFiles)
/// }
/// }
///
/// ### Controlling label visibility
///
/// A label communicates the identity or purpose of the value, which is
/// important for accessibility. However, you might want to hide the label
/// in the display, and some controls or contexts may visually hide their label
/// by default. The ``View/labels(_:)`` modifier allows controlling that
/// visibility. The following example hides both labels, producing only a
/// group of the two value views:
///
/// Group {
/// LabeledContent("Custom Value") {
/// MyCustomView(value: $value)
/// }
/// Picker("Selected Value", selection: $selection) {
/// PickerOption("Option 1", 1)
/// PickerOption("Option 2", 2)
/// }
/// }
/// .labelsHidden()
///
/// ### Styling labeled content
///
/// You can set label styles using the ``View/labeledContentStyle(_:)``
/// modifier. You can also build custom styles using ``LabeledContentStyle``.
public struct LabeledContent<Label, Content> {
@Environment(\.backportLabeledContentStyle) private var style
let config: LabeledContentStyleConfiguration
public var body: some View {
style.makeBody(configuration: config)
}
}
}
extension Backport.LabeledContent
where
Wrapped == Any, Label == Backport<Any>.LabeledContentStyleConfiguration.Label,
Content == Backport<Any>.LabeledContentStyleConfiguration.Content
{
/// Creates labeled content based on a labeled content style configuration.
///
/// You can use this initializer within the
/// ``LabeledContentStyle/makeBody(configuration:)`` method of a
/// ``LabeledContentStyle`` to create a labeled content instance.
/// This is useful for custom styles that only modify the current style,
/// as opposed to implementing a brand new style.
///
/// For example, the following style adds a red border around the labeled
/// content, but otherwise preserves the current style:
///
/// struct RedBorderLabeledContentStyle: LabeledContentStyle {
/// func makeBody(configuration: Configuration) -> some View {
/// LabeledContent(configuration)
/// .border(.red)
/// }
/// }
///
/// - Parameter configuration: The properties of the labeled content
public init(_ config: Backport.LabeledContentStyleConfiguration) {
self.config = config
}
}
extension Backport.LabeledContent where Wrapped == Any, Label == Text, Content: View {
/// Creates a labeled view that generates its label from a localized string
/// key.
///
/// This initializer creates a ``Text`` label on your behalf, and treats the
/// localized key similar to ``Text/init(_:tableName:bundle:comment:)``. See
/// `Text` for more information about localizing strings.
///
/// - Parameters:
/// - titleKey: The key for the view's localized title, that describes
/// the purpose of the view.
/// - content: The value content being labeled.
public init(_ titleKey: LocalizedStringKey, @ViewBuilder content: () -> Content) {
config = .init(
label: Text(titleKey),
content: content()
)
}
/// Creates a labeled view that generates its label from a string.
///
/// This initializer creates a ``Text`` label on your behalf, and treats the
/// title similar to ``Text/init(_:)-9d1g4``. See `Text` for more
/// information about localizing strings.
///
/// - Parameters:
/// - title: A string that describes the purpose of the view.
/// - content: The value content being labeled.
public init<S>(_ title: S, @ViewBuilder content: () -> Content) where S: StringProtocol {
config = .init(
label: Text(title),
content: content()
)
}
}
extension Backport.LabeledContent: View where Wrapped == Any, Label: View, Content: View {
/// Creates a labeled view that generates its label from a localized string
/// key.
///
/// This initializer creates a ``Text`` label on your behalf, and treats the
/// localized key similar to ``Text/init(_:tableName:bundle:comment:)``. See
/// `Text` for more information about localizing strings.
///
/// - Parameters:
/// - titleKey: The key for the view's localized title, that describes
/// the purpose of the view.
/// - content: The value content being labeled.
public init(@ViewBuilder content: () -> Content, @ViewBuilder label: () -> Label) {
config = .init(
label: label(),
content: content()
)
}
}
extension Backport.LabeledContent where Wrapped == Any, Label == Text, Content == Text {
/// Creates a labeled informational view.
///
/// This initializer creates a ``Text`` label on your behalf, and treats the
/// localized key similar to ``Text/init(_:tableName:bundle:comment:)``. See
/// `Text` for more information about localizing strings.
///
/// Form {
/// LabeledContent("Name", value: person.name)
/// }
///
/// In some contexts, this text will be selectable by default.
///
/// - Parameters:
/// - titleKey: The key for the view's localized title, that describes
/// the purpose of the view.
/// - value: The value being labeled.
public init<S: StringProtocol>(_ titleKey: LocalizedStringKey, value: S) {
config = .init(
label: Text(titleKey),
content: Text(value)
)
}
/// Creates a labeled informational view.
///
/// This initializer creates a ``Text`` label on your behalf, and treats the
/// title similar to ``Text/init(_:)-9d1g4``. See `Text` for more
/// information about localizing strings.
///
/// Form {
/// ForEach(person.pet) { pet in
/// LabeledContent(pet.species, value: pet.name)
/// }
/// }
///
/// - Parameters:
/// - title: A string that describes the purpose of the view.
/// - value: The value being labeled.
public init<S1: StringProtocol, S2: StringProtocol>(_ title: S1, value: S2) {
config = .init(
label: Text(title),
content: Text(value)
)
}
}

View File

@ -0,0 +1,56 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 16)
@available(tvOS, deprecated: 16)
@available(macOS, deprecated: 13)
@available(watchOS, deprecated: 9)
extension Backport where Wrapped: View {
/// Sets a style for labeled content.
public func labeledContentStyle<S>(_ style: S) -> some View where S: BackportLabeledContentStyle {
content.environment(\.backportLabeledContentStyle, .init(style))
}
}
@available(iOS, deprecated: 16)
@available(tvOS, deprecated: 16)
@available(macOS, deprecated: 13)
@available(watchOS, deprecated: 9)
public protocol BackportLabeledContentStyle {
typealias Configuration = Backport<Any>.LabeledContentStyleConfiguration
associatedtype Body: View
@ViewBuilder func makeBody(configuration: Configuration) -> Body
}
internal struct AnyLabeledContentStyle: BackportLabeledContentStyle {
typealias Configuration = Backport<Any>.LabeledContentStyleConfiguration
let _makeBody: (Configuration) -> AnyView
init<S: BackportLabeledContentStyle>(_ style: S) {
_makeBody = { config in
AnyView(style.makeBody(configuration: config))
}
}
func makeBody(configuration: Configuration) -> some View {
_makeBody(configuration)
}
}
private struct BackportLabeledContentStyleEnvironmentKey: EnvironmentKey {
static var defaultValue: AnyLabeledContentStyle = .init(.automatic)
}
@available(iOS, deprecated: 16)
@available(tvOS, deprecated: 16)
@available(macOS, deprecated: 13)
@available(watchOS, deprecated: 9)
extension EnvironmentValues {
var backportLabeledContentStyle: AnyLabeledContentStyle {
get { self[BackportLabeledContentStyleEnvironmentKey.self] }
set { self[BackportLabeledContentStyleEnvironmentKey.self] = newValue }
}
}

View File

@ -0,0 +1,62 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 16)
@available(tvOS, deprecated: 16)
@available(macOS, deprecated: 13)
@available(watchOS, deprecated: 9)
extension Backport where Wrapped == Any {
/// The properties of a labeled content instance.
public struct LabeledContentStyleConfiguration {
/// A type-erased label of a labeled content instance.
public struct Label: View {
@EnvironmentContains(key: "LabelsHiddenKey") private var isHidden
let view: AnyView
public var body: some View {
if isHidden {
EmptyView()
} else {
view
}
}
init<V: View>(_ view: V) {
self.view = .init(view)
}
}
/// A type-erased content of a labeled content instance.
public struct Content: View {
@EnvironmentContains(key: "LabelsHiddenKey") private var isHidden
let view: AnyView
public var body: some View {
view
.foregroundColor(isHidden ? .primary : .secondary)
.frame(maxWidth: .infinity, alignment: isHidden ? .leading : .trailing)
}
init<V: View>(_ view: V) {
self.view = .init(view)
}
}
/// The label of the labeled content instance.
public let label: Label
/// The content of the labeled content instance.
public let content: Content
internal init<L: View, C: View>(label: L, content: C) {
self.label = .init(label)
self.content = .init(content)
}
internal init<L: View, C: View>(@ViewBuilder content: () -> C, @ViewBuilder label: () -> L) {
self.content = .init(content())
self.label = .init(label())
}
}
}

View File

@ -0,0 +1,22 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
extension Backport where Wrapped == Any {
public struct AutomaticLabeledContentStyle: BackportLabeledContentStyle {
public func makeBody(configuration: Configuration) -> some View {
HStack(alignment: .firstTextBaseline) {
configuration.label
Spacer()
configuration.content
.multilineTextAlignment(.trailing)
}
}
}
}
extension BackportLabeledContentStyle where Self == Backport<Any>.AutomaticLabeledContentStyle {
static var automatic: Self { .init() }
}

View File

@ -0,0 +1,132 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 16)
@available(tvOS, deprecated: 16)
@available(watchOS, deprecated: 9)
@available(macOS, deprecated: 13)
extension Backport where Wrapped: View {
/// Associates a destination view with a presented data type for use within
/// a navigation stack.
///
/// Add this view modifer to a view inside a ``NavigationStack`` to
/// describe the view that the stack displays when presenting
/// a particular kind of data. Use a ``NavigationLink`` to present
/// the data. For example, you can present a `ColorDetail` view for
/// each presentation of a ``Color`` instance:
///
/// NavigationStack {
/// List {
/// NavigationLink("Mint", value: Color.mint)
/// NavigationLink("Pink", value: Color.pink)
/// NavigationLink("Teal", value: Color.teal)
/// }
/// .navigationDestination(for: Color.self) { color in
/// ColorDetail(color: color)
/// }
/// .navigationTitle("Colors")
/// }
///
/// You can add more than one navigation destination modifier to the stack
/// if it needs to present more than one kind of data.
///
/// - Parameters:
/// - data: The type of data that this destination matches.
/// - destination: A view builder that defines a view to display
/// when the stack's navigation state contains a value of
/// type `data`. The closure takes one argument, which is the value
/// of the data to present.
public func navigationDestination<D: Hashable, C: View>(for _: D.Type, @ViewBuilder destination: @escaping (D) -> C)
-> some View
{
content
.environment(
\.navigationDestinations,
[
.init(type: D.self): .init { destination($0 as! D) }
]
)
}
}
@available(iOS, deprecated: 16)
@available(tvOS, deprecated: 16)
@available(watchOS, deprecated: 9)
@available(macOS, deprecated: 13)
extension Backport where Wrapped == Any {
public struct NavigationLink<Label, Destination>: View where Label: View, Destination: View {
@Environment(\.navigationDestinations) private var destinations
private let valueType: AnyMetaType
private let value: Any?
private let label: Label
private let destination: () -> Destination
public init<P>(value: P?, @ViewBuilder label: () -> Label) where Destination == Never {
self.value = value
valueType = .init(type: P.self)
destination = { fatalError() }
self.label = label()
}
public var body: some View {
SwiftUI.NavigationLink {
if let value = value {
destinations[valueType.type]?.content(value)
}
} label: {
label
}
.disabled(value == nil)
}
}
}
private struct NavigationDestinationsEnvironmentKey: EnvironmentKey {
static var defaultValue: [AnyMetaType: DestinationView] = [:]
}
extension EnvironmentValues {
fileprivate var navigationDestinations: [AnyMetaType: DestinationView] {
get { self[NavigationDestinationsEnvironmentKey.self] }
set {
var current = self[NavigationDestinationsEnvironmentKey.self]
newValue.forEach { current[$0] = $1 }
self[NavigationDestinationsEnvironmentKey.self] = current
}
}
}
private struct AnyMetaType {
let type: Any.Type
}
extension AnyMetaType: Equatable {
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.type == rhs.type
}
}
extension AnyMetaType: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(ObjectIdentifier(type))
}
}
extension Dictionary {
fileprivate subscript(_ key: Any.Type) -> Value? where Key == AnyMetaType {
get { self[.init(type: key)] }
_modify { yield &self[.init(type: key)] }
}
}
private struct DestinationView: View {
let content: (Any) -> AnyView
var body: Never { fatalError() }
init<Content: View>(content: @escaping (Any) -> Content) {
self.content = { AnyView(content($0)) }
}
}

View File

@ -0,0 +1,36 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 14)
@available(watchOS, deprecated: 7)
@available(tvOS, deprecated: 14)
extension Backport where Wrapped: View {
@ViewBuilder
public func navigationTitle<S: StringProtocol>(_ title: S) -> some View {
#if os(macOS)
if #available(macOS 11, *) {
content.navigationTitle(title)
} else {
content
}
#else
content.navigationBarTitle(title)
#endif
}
@ViewBuilder
public func navigationTitle(_ titleKey: LocalizedStringKey) -> some View {
#if os(macOS)
if #available(macOS 11, *) {
content.navigationTitle(titleKey)
} else {
content
}
#else
content.navigationBarTitle(titleKey)
#endif
}
}

View File

@ -0,0 +1,50 @@
import Combine
import SwiftUI
@available(iOS, deprecated: 14.0)
@available(macOS, deprecated: 11.0)
@available(tvOS, deprecated: 14.0)
@available(watchOS, deprecated: 7.0)
extension Backport where Wrapped: View {
/// Adds a modifier for this view that fires an action when a specific
/// value changes.
///
/// `onChange` is called on the main thread. Avoid performing long-running
/// tasks on the main thread. If you need to perform a long-running task in
/// response to `value` changing, you should dispatch to a background queue.
///
/// The new value is passed into the closure.
///
/// - Parameters:
/// - value: The value to observe for changes
/// - action: A closure to run when the value changes.
/// - newValue: The new value that changed
///
/// - Returns: A view that fires an action when the specified value changes.
@ViewBuilder
public func onChange<Value: Equatable>(of value: Value, perform action: @escaping (Value) -> Void) -> some View {
content.modifier(ChangeModifier(value: value, action: action))
}
}
private struct ChangeModifier<Value: Equatable>: ViewModifier {
let value: Value
let action: (Value) -> Void
@State var oldValue: Value?
init(value: Value, action: @escaping (Value) -> Void) {
self.value = value
self.action = action
_oldValue = .init(initialValue: value)
}
func body(content: Content) -> some View {
content
.onReceive(Just(value)) { newValue in
guard newValue != oldValue else { return }
action(newValue)
oldValue = newValue
}
}
}

View File

@ -0,0 +1,165 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
#if canImport(WatchKit)
import WatchKit
#endif
@available(iOS, deprecated: 14)
@available(tvOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(watchOS, deprecated: 7)
extension Backport where Wrapped == Any {
/// An action that opens a URL.
///
/// Read the ``EnvironmentValues.backportOpenURL`` environment value to get an
/// instance of this structure for a given ``Environment``. Call the
/// instance to open a URL. You call the instance directly because it
/// defines a ``Backport.OpenURLAction.callAsFunction(_:)`` method that Swift
/// calls when you call the instance.
///
/// For example, you can open a web site when the user taps a button:
///
/// struct OpenURLExample: View {
/// @Environment(\.backportOpenURL) private var openURL
///
/// var body: some View {
/// Button {
/// if let url = URL(string: "https://www.example.com") {
/// openURL(url)
/// }
/// } label: {
/// Label("Get Help", systemImage: "person.fill.questionmark")
/// }
/// }
/// }
///
/// If you want to know whether the action succeeds, add a completion
/// handler that takes a Boolean value. In this case, Swift implicitly
/// calls the ``Backport.OpenURLAction.callAsFunction(_:completion:)`` method
/// instead. That method calls your completion handler after it determines
/// whether it can open the URL, but possibly before it finishes opening
/// the URL. You can add a handler to the example above so that
/// it prints the outcome to the console:
///
/// openURL(url) { accepted in
/// print(accepted ? "Success" : "Failure")
/// }
///
/// The system provides a default open URL action with behavior
/// that depends on the contents of the URL. For example, the default
/// action opens a Universal Link in the associated app if possible,
/// or in the users default web browser if not.
///
/// You can also set a custom action using the ``View.environment(_:_:)``
/// view modifier. Any views that read the action from the environment,
/// including the built-in ``Link`` view and ``Text`` views with markdown
/// links, or links in attributed strings, use your action. Initialize an
/// action by calling the ``Backport.OpenURLAction.init(handler:)`` initializer with
/// a handler that takes a URL and returns an ``Backport.OpenURLAction.Result``:
///
/// Text("Visit [Example Company](https://www.example.com) for details.")
/// .environment(\.backportOpenURL, Backport.OpenURLAction { url in
/// handleURL(url) // Define this method to take appropriate action.
/// return .handled
/// })
///
/// SwiftUI translates the value that your custom action's handler
/// returns into an appropriate Boolean result for the action call.
/// For example, a view that uses the action declared above
/// receives `true` when calling the action, because the
/// handler always returns ``Backport.OpenURLAction.Result.handled``.
public struct OpenURLAction {
@available(iOS, deprecated: 15)
@available(tvOS, deprecated: 15)
@available(macOS, deprecated: 12)
@available(watchOS, deprecated: 8)
public struct Result {
enum Value {
case handled
case discarded
case systemAction(_ url: URL?)
var accepted: Bool {
if case .discarded = self {
return false
} else {
return true
}
}
}
let value: Value
public static var handled: Result { .init(value: .handled) }
public static var discarded: Result { .init(value: .discarded) }
public static var systemAction: Result { .init(value: .systemAction(nil)) }
public static func systemAction(_ url: URL) -> Result { .init(value: .systemAction(url)) }
}
let handler: (URL) -> Result
public init(handler: @escaping (URL) -> Result) {
self.handler = handler
}
@available(watchOS, unavailable)
public func callAsFunction(_ url: URL) {
handleUrl(url)
}
@available(watchOS, unavailable)
public func callAsFunction(_ url: URL, completion: @escaping (_ accepted: Bool) -> Void) {
let result = handleUrl(url)
completion(result.accepted)
}
@discardableResult
private func handleUrl(_ url: URL) -> Result.Value {
let result = handler(url).value
switch result {
case .handled, .discarded: break
case let .systemAction(updatedUrl):
let resolved = updatedUrl ?? url
#if os(macOS)
NSWorkspace.shared.open(resolved)
#elseif os(iOS) || os(tvOS)
UIApplication.shared.open(resolved)
#else
WKExtension.shared().openSystemURL(resolved)
#endif
}
return result
}
}
}
private struct BackportOpenURLKey: EnvironmentKey {
static var defaultValue: Backport<Any>.OpenURLAction {
.init { url in
#if os(macOS)
return .systemAction
#elseif os(iOS) || os(tvOS)
if UIApplication.shared.canOpenURL(url) {
return .systemAction
} else {
return .discarded
}
#else
return .systemAction
#endif
}
}
}
extension EnvironmentValues {
public var backportOpenURL: Backport<Any>.OpenURLAction {
get { self[BackportOpenURLKey.self] }
set { self[BackportOpenURLKey.self] = newValue }
}
}

View File

@ -0,0 +1,127 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
extension Backport where Wrapped: View {
/// Layers the views that you specify in front of this view.
///
/// Use this modifier to place one or more views in front of another view.
/// For example, you can place a group of stars on a ``RoundedRectangle``:
///
/// RoundedRectangle(cornerRadius: 8)
/// .frame(width: 200, height: 100)
/// .overlay(alignment: .topLeading) { Star(color: .red) }
/// .overlay(alignment: .topTrailing) { Star(color: .yellow) }
/// .overlay(alignment: .bottomLeading) { Star(color: .green) }
/// .overlay(alignment: .bottomTrailing) { Star(color: .blue) }
///
/// The example above assumes that you've defined a `Star` view with a
/// parameterized color:
///
/// struct Star: View {
/// var color = Color.yellow
///
/// var body: some View {
/// Image(systemName: "star.fill")
/// .foregroundStyle(color)
/// }
/// }
///
/// By setting different `alignment` values for each modifier, you make the
/// stars appear in different places on the rectangle:
///
/// ![A screenshot of a rounded rectangle with a star in each corner. The
/// star in the upper-left is red; the start in the upper-right is yellow;
/// the star in the lower-left is green; the star the lower-right is
/// blue.](View-overlay-2)
///
/// If you specify more than one view in the `content` closure, the modifier
/// collects all of the views in the closure into an implicit ``ZStack``,
/// taking them in order from back to front. For example, you can place a
/// star and a ``Circle`` on a field of ``ShapeStyle/blue``:
///
/// Color.blue
/// .frame(width: 200, height: 200)
/// .overlay {
/// Circle()
/// .frame(width: 100, height: 100)
/// Star()
/// }
///
/// Both the overlay modifier and the implicit ``ZStack`` composed from the
/// overlay content --- the circle and the star --- use a default
/// ``Alignment/center`` alignment. The star appears centered on the circle,
/// and both appear as a composite view centered in front of the square:
///
/// ![A screenshot of a star centered on a circle, which is
/// centered on a square.](View-overlay-3)
///
/// If you specify an alignment for the overlay, it applies to the implicit
/// stack rather than to the individual views in the closure. You can see
/// this if you add the ``Alignment/bottom`` alignment:
///
/// Color.blue
/// .frame(width: 200, height: 200)
/// .overlay(alignment: .bottom) {
/// Circle()
/// .frame(width: 100, height: 100)
/// Star()
/// }
///
/// The circle and the star move down as a unit to align the stack's bottom
/// edge with the bottom edge of the square, while the star remains
/// centered on the circle:
///
/// ![A screenshot of a star centered on a circle, which is on a square.
/// The circle's bottom edge is aligned with the square's bottom
/// edge.](View-overlay-3a)
///
/// To control the placement of individual items inside the `content`
/// closure, either use a different overlay modifier for each item, as the
/// earlier example of stars in the corners of a rectangle demonstrates, or
/// add an explicit ``ZStack`` inside the content closure with its own
/// alignment:
///
/// Color.blue
/// .frame(width: 200, height: 200)
/// .overlay(alignment: .bottom) {
/// ZStack(alignment: .bottom) {
/// Circle()
/// .frame(width: 100, height: 100)
/// Star()
/// }
/// }
///
/// The stack alignment ensures that the star's bottom edge aligns with the
/// circle's, while the overlay aligns the composite view with the square:
///
/// ![A screenshot of a star, a circle, and a square with all their
/// bottom edges aligned.](View-overlay-4)
///
/// You can achieve layering without an overlay modifier by putting both the
/// modified view and the overlay content into a ``ZStack``. This can
/// produce a simpler view hierarchy, but changes the layout priority that
/// SwiftUI applies to the views. Use the overlay modifier when you want the
/// modified view to dominate the layout.
///
/// If you want to specify a ``ShapeStyle`` like a ``Color`` or a
/// ``Material`` as the overlay, use
/// ``View/overlay(_:ignoresSafeAreaEdges:)`` instead. To specify a
/// ``Shape``, use ``View/overlay(_:in:fillStyle:)``.
///
/// - Parameters:
/// - alignment: The alignment that the modifier uses to position the
/// implicit ``ZStack`` that groups the foreground views. The default
/// is ``Alignment/center``.
/// - content: A ``ViewBuilder`` that you use to declare the views to
/// draw in front of this view, stacked in the order that you list them.
/// The last view that you list appears at the front of the stack.
///
/// - Returns: A view that uses the specified content as a foreground.
public func overlay<Content: View>(alignment: Alignment = .center, @ViewBuilder _ content: () -> Content) -> some View
{
self.content.overlay(content(), alignment: alignment)
}
}

View File

@ -0,0 +1,241 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14.0)
@available(watchOS, deprecated: 7.0)
extension Backport where Wrapped == Any {
public struct ProgressView<Label: View, CurrentValueLabel: View>: View {
@Environment(\.backportProgressViewStyle) private var style
let config: Backport<Any>.ProgressViewStyleConfiguration
public var body: some View {
Group {
if let style = style {
style.makeBody(configuration: config)
} else {
DefaultProgressViewStyle().makeBody(configuration: config)
}
}
}
}
}
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14.0)
@available(watchOS, unavailable)
extension Backport.ProgressView where Wrapped == Any, CurrentValueLabel == EmptyView {
/// Creates a progress view for showing indeterminate progress, without a
/// label.
public init() where Label == EmptyView {
self.init(config: .init(fractionCompleted: nil, preferredKind: .circular))
}
/// Creates a progress view for showing indeterminate progress that displays
/// a custom label.
///
/// - Parameters:
/// - label: A view builder that creates a view that describes the task
/// in progress.
public init(@ViewBuilder label: () -> Label) {
config = .init(fractionCompleted: nil, label: .init(content: label()), preferredKind: .circular)
}
/// Creates a progress view for showing indeterminate progress that
/// generates its label from a localized string.
///
/// This initializer creates a ``Text`` view on your behalf, and treats the
/// localized key similar to ``Text/init(_:tableName:bundle:comment:)``. See
/// ``Text`` for more information about localizing strings. To initialize a
/// indeterminate progress view with a string variable, use
/// the corresponding initializer that takes a `StringProtocol` instance.
///
/// - Parameters:
/// - titleKey: The key for the progress view's localized title that
/// describes the task in progress.
public init(_ titleKey: LocalizedStringKey) where Label == Text {
config = .init(fractionCompleted: nil, label: .init(content: Text(titleKey)), preferredKind: .circular)
}
/// Creates a progress view for showing indeterminate progress that
/// generates its label from a string.
///
/// - Parameters:
/// - title: A string that describes the task in progress.
///
/// This initializer creates a ``Text`` view on your behalf, and treats the
/// title similar to ``Text/init(verbatim:)``. See ``Text`` for more
/// information about localizing strings. To initialize a progress view with
/// a localized string key, use the corresponding initializer that takes a
/// `LocalizedStringKey` instance.
public init<S>(_ title: S) where Label == Text, S: StringProtocol {
config = .init(fractionCompleted: nil, label: .init(content: Text(title)), preferredKind: .circular)
}
}
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14.0)
@available(watchOS, deprecated: 7.0)
extension Backport.ProgressView where Wrapped == Any {
/// Creates a progress view for showing determinate progress.
///
/// If the value is non-`nil`, but outside the range of `0.0` through
/// `total`, the progress view pins the value to those limits, rounding to
/// the nearest possible bound. A value of `nil` represents indeterminate
/// progress, in which case the progress view ignores `total`.
///
/// - Parameters:
/// - value: The completed amount of the task to this point, in a range
/// of `0.0` to `total`, or `nil` if the progress is indeterminate.
/// - total: The full amount representing the complete scope of the
/// task, meaning the task is complete if `value` equals `total`. The
/// default value is `1.0`.
public init<V>(value: V?, total: V = 1.0)
where Label == EmptyView, CurrentValueLabel == EmptyView, V: BinaryFloatingPoint {
if let value = value {
config = .init(fractionCompleted: Double(value) / Double(total), preferredKind: .linear, max: Double(total))
} else {
config = .init(fractionCompleted: nil, preferredKind: .linear)
}
}
/// Creates a progress view for showing determinate progress, with a
/// custom label.
///
/// If the value is non-`nil`, but outside the range of `0.0` through
/// `total`, the progress view pins the value to those limits, rounding to
/// the nearest possible bound. A value of `nil` represents indeterminate
/// progress, in which case the progress view ignores `total`.
///
/// - Parameters:
/// - value: The completed amount of the task to this point, in a range
/// of `0.0` to `total`, or `nil` if the progress is indeterminate.
/// - total: The full amount representing the complete scope of the
/// task, meaning the task is complete if `value` equals `total`. The
/// default value is `1.0`.
/// - label: A view builder that creates a view that describes the task
/// in progress.
public init<V>(value: V?, total: V = 1.0, @ViewBuilder label: () -> Label)
where CurrentValueLabel == EmptyView, V: BinaryFloatingPoint {
if let value = value {
config = .init(
fractionCompleted: Double(value) / Double(total), label: .init(content: label()), preferredKind: .linear
)
} else {
config = .init(fractionCompleted: nil, label: .init(content: label()), preferredKind: .linear, max: Double(total))
}
}
/// Creates a progress view for showing determinate progress, with a
/// custom label.
///
/// If the value is non-`nil`, but outside the range of `0.0` through
/// `total`, the progress view pins the value to those limits, rounding to
/// the nearest possible bound. A value of `nil` represents indeterminate
/// progress, in which case the progress view ignores `total`.
///
/// - Parameters:
/// - value: The completed amount of the task to this point, in a range
/// of `0.0` to `total`, or `nil` if the progress is indeterminate.
/// - total: The full amount representing the complete scope of the
/// task, meaning the task is complete if `value` equals `total`. The
/// default value is `1.0`.
/// - label: A view builder that creates a view that describes the task
/// in progress.
/// - currentValueLabel: A view builder that creates a view that
/// describes the level of completed progress of the task.
public init<V>(
value: V?, total: V = 1.0, @ViewBuilder label: () -> Label, @ViewBuilder currentValueLabel: () -> CurrentValueLabel
) where V: BinaryFloatingPoint {
if let value = value {
config = .init(
fractionCompleted: Double(value) / Double(total), label: .init(content: label()),
currentValueLabel: .init(content: currentValueLabel()), preferredKind: .linear, max: Double(total)
)
} else {
config = .init(
fractionCompleted: nil, label: .init(content: label()), currentValueLabel: .init(content: currentValueLabel()),
preferredKind: .linear, max: Double(total)
)
}
}
/// Creates a progress view for showing determinate progress that generates
/// its label from a localized string.
///
/// If the value is non-`nil`, but outside the range of `0.0` through
/// `total`, the progress view pins the value to those limits, rounding to
/// the nearest possible bound. A value of `nil` represents indeterminate
/// progress, in which case the progress view ignores `total`.
///
/// This initializer creates a ``Text`` view on your behalf, and treats the
/// localized key similar to ``Text/init(_:tableName:bundle:comment:)``. See
/// ``Text`` for more information about localizing strings. To initialize a
/// determinate progress view with a string variable, use
/// the corresponding initializer that takes a `StringProtocol` instance.
///
/// - Parameters:
/// - titleKey: The key for the progress view's localized title that
/// describes the task in progress.
/// - value: The completed amount of the task to this point, in a range
/// of `0.0` to `total`, or `nil` if the progress is
/// indeterminate.
/// - total: The full amount representing the complete scope of the
/// task, meaning the task is complete if `value` equals `total`. The
/// default value is `1.0`.
public init<V>(_ titleKey: LocalizedStringKey, value: V?, total: V = 1.0)
where Label == Text, CurrentValueLabel == EmptyView, V: BinaryFloatingPoint {
if let value = value {
config = .init(
fractionCompleted: Double(value) / Double(total), label: .init(content: Text(titleKey)), preferredKind: .linear,
max: Double(total)
)
} else {
config = .init(
fractionCompleted: nil, label: .init(content: Text(titleKey)), preferredKind: .linear, max: Double(total)
)
}
}
/// Creates a progress view for showing determinate progress that generates
/// its label from a string.
///
/// If the value is non-`nil`, but outside the range of `0.0` through
/// `total`, the progress view pins the value to those limits, rounding to
/// the nearest possible bound. A value of `nil` represents indeterminate
/// progress, in which case the progress view ignores `total`.
///
/// This initializer creates a ``Text`` view on your behalf, and treats the
/// title similar to ``Text/init(verbatim:)``. See ``Text`` for more
/// information about localizing strings. To initialize a determinate
/// progress view with a localized string key, use the corresponding
/// initializer that takes a `LocalizedStringKey` instance.
///
/// - Parameters:
/// - title: The string that describes the task in progress.
/// - value: The completed amount of the task to this point, in a range
/// of `0.0` to `total`, or `nil` if the progress is
/// indeterminate.
/// - total: The full amount representing the complete scope of the
/// task, meaning the task is complete if `value` equals `total`. The
/// default value is `1.0`.
public init<S, V>(_ title: S, value: V?, total: V = 1.0)
where Label == Text, CurrentValueLabel == EmptyView, S: StringProtocol, V: BinaryFloatingPoint {
if let value = value {
config = .init(
fractionCompleted: Double(value) / Double(total), label: .init(content: Text(title)), preferredKind: .linear,
max: Double(total)
)
} else {
config = .init(
fractionCompleted: nil, label: .init(content: Text(title)), preferredKind: .linear, max: Double(total)
)
}
}
}

View File

@ -0,0 +1,66 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14.0)
@available(watchOS, deprecated: 7.0)
extension Backport where Wrapped == Any {
/// The properties of a progress view instance.
public struct ProgressViewStyleConfiguration {
internal enum Kind {
case circular
case linear
}
/// A type-erased label describing the task represented by the progress
/// view.
public struct Label: View {
let content: AnyView
public var body: some View { content }
init<Content: View>(content: Content) {
self.content = .init(content)
}
}
/// A type-erased label that describes the current value of a progress view.
public struct CurrentValueLabel: View {
let content: AnyView
public var body: some View { content }
init<Content: View>(content: Content) {
self.content = .init(content)
}
}
/// The completed fraction of the task represented by the progress view,
/// from `0.0` (not yet started) to `1.0` (fully complete), or `nil` if the
/// progress is indeterminate or relative to a date interval.
public let fractionCompleted: Double?
/// A view that describes the task represented by the progress view.
///
/// If `nil`, then the task is self-evident from the surrounding context,
/// and the style does not need to provide any additional description.
///
/// If the progress view is defined using a `Progress` instance, then this
/// label is equivalent to its `localizedDescription`.
public var label: Label?
/// A view that describes the current value of a progress view.
///
/// If `nil`, then the value of the progress view is either self-evident
/// from the surrounding context or unknown, and the style does not need to
/// provide any additional description.
///
/// If the progress view is defined using a `Progress` instance, then this
/// label is equivalent to its `localizedAdditionalDescription`.
public var currentValueLabel: CurrentValueLabel?
internal let preferredKind: Kind
internal var min: Double = 0
internal var max: Double = 1
}
}

View File

@ -0,0 +1,69 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14.0)
@available(watchOS, deprecated: 7.0)
/// A type that applies standard interaction behavior to all progress views
/// within a view hierarchy.
///
/// To configure the current progress view style for a view hierarchy, use the
/// ``View/progressViewStyle(_:)`` modifier.
public protocol BackportProgressViewStyle {
/// A type alias for the properties of a progress view instance.
typealias Configuration = Backport<Any>.ProgressViewStyleConfiguration
/// A view representing the body of a progress view.
associatedtype Body: View
/// Creates a view representing the body of a progress view.
///
/// - Parameter configuration: The properties of the progress view being
/// created.
///
/// The view hierarchy calls this method for each progress view where this
/// style is the current progress view style.
///
/// - Parameter configuration: The properties of the progress view, such as
/// its preferred progress type.
@ViewBuilder func makeBody(configuration: Configuration) -> Body
}
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14.0)
@available(watchOS, deprecated: 7.0)
extension Backport where Wrapped: View {
public func progressViewStyle<S: BackportProgressViewStyle>(_ style: S) -> some View {
content.environment(\.backportProgressViewStyle, .init(style))
}
}
internal struct AnyProgressViewStyle: BackportProgressViewStyle {
let _makeBody: (Backport<Any>.ProgressViewStyleConfiguration) -> AnyView
init<S: BackportProgressViewStyle>(_ style: S) {
_makeBody = { config in
AnyView(style.makeBody(configuration: config))
}
}
func makeBody(configuration: Configuration) -> some View {
_makeBody(configuration)
}
}
private struct BackportProgressViewStyleEnvironmentKey: EnvironmentKey {
static var defaultValue: AnyProgressViewStyle?
}
extension EnvironmentValues {
var backportProgressViewStyle: AnyProgressViewStyle? {
get { self[BackportProgressViewStyleEnvironmentKey.self] }
set { self[BackportProgressViewStyleEnvironmentKey.self] = newValue }
}
}

View File

@ -0,0 +1,80 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14.0)
@available(watchOS, deprecated: 7.0)
extension Backport where Wrapped == Any {
/// A progress view that visually indicates its progress using a circular gauge.
///
/// You can also use ``ProgressViewStyle/circular`` to construct this style.
public struct CircularProgressViewStyle: BackportProgressViewStyle {
/// Creates a circular progress view style.
public init() {}
/// Creates a view representing the body of a progress view.
///
/// - Parameter configuration: The properties of the progress view being
/// created.
///
/// The view hierarchy calls this method for each progress view where this
/// style is the current progress view style.
///
/// - Parameter configuration: The properties of the progress view, such as
/// its preferred progress type.
public func makeBody(configuration: Configuration) -> some View {
VStack {
#if !os(watchOS)
CircularRepresentable(configuration: configuration)
#endif
configuration.label?
.foregroundColor(.secondary)
}
}
}
}
extension BackportProgressViewStyle where Self == Backport<Any>.CircularProgressViewStyle {
public static var circular: Self { .init() }
}
#if os(macOS)
private struct CircularRepresentable: NSViewRepresentable {
let configuration: Backport<Any>.ProgressViewStyleConfiguration
func makeNSView(context _: Context) -> NSProgressIndicator {
.init()
}
func updateNSView(_ view: NSProgressIndicator, context _: Context) {
if let value = configuration.fractionCompleted {
view.doubleValue = value
view.maxValue = configuration.max
}
view.isIndeterminate = configuration.fractionCompleted == nil
view.style = .spinning
view.isDisplayedWhenStopped = true
view.startAnimation(nil)
}
}
#elseif !os(watchOS)
private struct CircularRepresentable: UIViewRepresentable {
let configuration: Backport<Any>.ProgressViewStyleConfiguration
func makeUIView(context _: Context) -> UIActivityIndicatorView {
.init(style: .medium)
}
func updateUIView(_ view: UIActivityIndicatorView, context _: Context) {
view.hidesWhenStopped = false
view.startAnimating()
}
}
#endif

View File

@ -0,0 +1,51 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14.0)
@available(watchOS, deprecated: 7.0)
extension Backport where Wrapped == Any {
/// The default progress view style in the current context of the view being
/// styled.
///
/// You can also use ``ProgressViewStyle/automatic`` to construct this style.
public struct DefaultProgressViewStyle: BackportProgressViewStyle {
/// Creates a default progress view style.
public init() {}
/// Creates a view representing the body of a progress view.
///
/// - Parameter configuration: The properties of the progress view being
/// created.
///
/// The view hierarchy calls this method for each progress view where this
/// style is the current progress view style.
///
/// - Parameter configuration: The properties of the progress view, such as
/// its preferred progress type.
public func makeBody(configuration: Configuration) -> some View {
switch configuration.preferredKind {
case .circular:
Backport.CircularProgressViewStyle().makeBody(configuration: configuration)
case .linear:
#if os(iOS)
if configuration.fractionCompleted == nil {
Backport.CircularProgressViewStyle().makeBody(configuration: configuration)
} else {
Backport.LinearProgressViewStyle().makeBody(configuration: configuration)
}
#else
Backport.LinearProgressViewStyle().makeBody(configuration: configuration)
#endif
}
}
}
}
extension BackportProgressViewStyle where Self == Backport<Any>.DefaultProgressViewStyle {
public static var automatic: Self { .init() }
}

View File

@ -0,0 +1,102 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14.0)
@available(watchOS, deprecated: 7.0)
extension Backport where Wrapped == Any {
/// A progress view that visually indicates its progress using a horizontal bar.
///
/// You can also use ``ProgressViewStyle/linear`` to construct this style.
public struct LinearProgressViewStyle: BackportProgressViewStyle {
/// Creates a linear progress view style.
public init() {}
/// Creates a view representing the body of a progress view.
///
/// - Parameter configuration: The properties of the progress view being
/// created.
///
/// The view hierarchy calls this method for each progress view where this
/// style is the current progress view style.
///
/// - Parameter configuration: The properties of the progress view, such as
/// its preferred progress type.
public func makeBody(configuration: Configuration) -> some View {
#if os(macOS)
VStack(alignment: .leading, spacing: 0) {
configuration.label
.foregroundColor(.primary)
LinearRepresentable(configuration: configuration)
configuration.currentValueLabel
.foregroundColor(.secondary)
}
.controlSize(.small)
#else
VStack(alignment: .leading, spacing: 5) {
if configuration.fractionCompleted == nil {
CircularProgressViewStyle().makeBody(configuration: configuration)
} else {
configuration.label?
.foregroundColor(.primary)
#if !os(watchOS)
LinearRepresentable(configuration: configuration)
#endif
configuration.currentValueLabel?
.foregroundColor(.secondary)
.font(.caption)
}
}
#endif
}
}
}
extension BackportProgressViewStyle where Self == Backport<Any>.LinearProgressViewStyle {
public static var linear: Self { .init() }
}
#if os(macOS)
private struct LinearRepresentable: NSViewRepresentable {
let configuration: Backport<Any>.ProgressViewStyleConfiguration
func makeNSView(context _: Context) -> NSProgressIndicator {
.init()
}
func updateNSView(_ view: NSProgressIndicator, context _: Context) {
if let value = configuration.fractionCompleted {
view.doubleValue = value
view.maxValue = configuration.max
view.display()
}
view.style = .bar
view.isIndeterminate = configuration.fractionCompleted == nil
view.isDisplayedWhenStopped = true
view.startAnimation(nil)
}
}
#elseif !os(watchOS)
private struct LinearRepresentable: UIViewRepresentable {
let configuration: Backport<Any>.ProgressViewStyleConfiguration
func makeUIView(context _: Context) -> UIProgressView {
.init(progressViewStyle: .default)
}
func updateUIView(_ view: UIProgressView, context _: Context) {
view.progress = Float(configuration.fractionCompleted ?? 0)
}
}
#endif

View File

@ -0,0 +1,87 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
#if os(iOS)
import QuickLook
final class PreviewController<Items>: UIViewController, UIAdaptivePresentationControllerDelegate,
QLPreviewControllerDelegate, QLPreviewControllerDataSource
where Items: RandomAccessCollection, Items.Element == URL {
var items: Items
var selection: Binding<Items.Element?> {
didSet {
updateControllerLifecycle(
from: oldValue.wrappedValue,
to: selection.wrappedValue
)
}
}
init(selection: Binding<Items.Element?>, in items: Items) {
self.selection = selection
self.items = items
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateControllerLifecycle(from oldValue: Items.Element?, to newValue: Items.Element?) {
switch (oldValue, newValue) {
case (.none, .some):
presentController()
case (.some, .some):
updateController()
case (.some, .none):
dismissController()
case (.none, .none):
break
}
}
private func presentController() {
print("Present")
let controller = QLPreviewController(nibName: nil, bundle: nil)
controller.dataSource = self
controller.delegate = self
present(controller, animated: true)
updateController()
}
private func updateController() {
let controller = presentedViewController as? QLPreviewController
controller?.reloadData()
let index = selection.wrappedValue.flatMap { items.firstIndex(of: $0) }
controller?.currentPreviewItemIndex = items.distance(from: items.startIndex, to: index ?? items.startIndex)
}
private func dismissController() {
DispatchQueue.main.async {
self.selection.wrappedValue = nil
}
}
func numberOfPreviewItems(in _: QLPreviewController) -> Int {
items.isEmpty ? 1 : items.count
}
func previewController(_: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
if items.isEmpty {
return (selection.wrappedValue ?? URL(fileURLWithPath: "")) as NSURL
} else {
let index = items.index(items.startIndex, offsetBy: index)
return items[index] as NSURL
}
}
func previewControllerDidDismiss(_: QLPreviewController) {
dismissController()
}
}
#endif

View File

@ -0,0 +1,113 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
#if os(macOS)
import QuickLook
import QuickLookUI
final class PreviewController<Items>: NSViewController, QLPreviewPanelDataSource, QLPreviewPanelDelegate
where Items: RandomAccessCollection, Items.Element == URL {
private let panel = QLPreviewPanel.shared()!
private weak var windowResponder: NSResponder?
var items: Items
var selection: Binding<Items.Element?> {
didSet {
updateControllerLifecycle(
from: oldValue.wrappedValue,
to: selection.wrappedValue
)
}
}
private func updateControllerLifecycle(from oldValue: Items.Element?, to newValue: Items.Element?) {
switch (oldValue, newValue) {
case (.none, .some):
present()
case (.some, .some):
update()
case (.some, .none):
dismiss()
case (.none, .none):
break
}
}
init(selection: Binding<Items.Element?>, in items: Items) {
self.selection = selection
self.items = items
super.init(nibName: nil, bundle: nil)
windowResponder = NSApp.mainWindow?.nextResponder
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
view = .init(frame: .zero)
}
var isVisible: Bool {
QLPreviewPanel.sharedPreviewPanelExists() && panel.isVisible
}
private func present() {
print("Present")
NSApp.mainWindow?.nextResponder = self
if isVisible {
panel.updateController()
let index = selection.wrappedValue.flatMap { items.firstIndex(of: $0) }
panel.currentPreviewItemIndex = items.distance(from: items.startIndex, to: index ?? items.startIndex)
} else {
panel.makeKeyAndOrderFront(nil)
}
}
private func update() {
present()
}
private func dismiss() {
selection.wrappedValue = nil
}
func numberOfPreviewItems(in _: QLPreviewPanel!) -> Int {
items.isEmpty ? 1 : items.count
}
func previewPanel(_: QLPreviewPanel!, previewItemAt index: Int) -> QLPreviewItem! {
if items.isEmpty {
return selection.wrappedValue as? NSURL
} else {
let index = items.index(items.startIndex, offsetBy: index)
return items[index] as NSURL
}
}
override func acceptsPreviewPanelControl(_: QLPreviewPanel!) -> Bool {
print("Accept")
return true
}
override func beginPreviewPanelControl(_ panel: QLPreviewPanel!) {
print("Begin")
panel.dataSource = self
panel.reloadData()
}
override func endPreviewPanelControl(_ panel: QLPreviewPanel!) {
print("End")
panel.dataSource = nil
dismiss()
}
}
#endif

View File

@ -0,0 +1,96 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
#if canImport(QuickLook)
import QuickLook
#endif
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
extension Backport where Wrapped: View {
/// Presents a Quick Look preview of the URLs you provide.
///
/// The Quick Look preview appears when you set the binding to a non-`nil` item.
/// When you set the item back to `nil`, Quick Look dismisses the preview.
/// If the value of the selection binding isnt contained in the items collection, Quick Look treats it the same as a `nil` selection.
///
/// Quick Look updates the value of the selection binding to match the URL of the file the user is previewing.
/// Upon dismissal by the user, Quick Look automatically sets the item binding to `nil`.
///
/// - Parameters:
/// - selection: A <doc://com.apple.documentation/documentation/SwiftUI/Binding> to an element thats part of the items collection. This is the URL that you currently want to preview.
/// - items: A collection of URLs to preview.
///
/// - Returns: A view that presents the preview of the contents of the URL.
public func quickLookPreview<Items>(_ selection: Binding<Items.Element?>, in items: Items) -> some View
where Items: RandomAccessCollection, Items.Element == URL {
#if os(iOS) || os(macOS)
content.background(QuicklookSheet(selection: selection, items: items))
#else
content
#endif
}
/// Presents a Quick Look preview of the contents of a single URL.
///
/// The Quick Look preview appears when you set the binding to a non-`nil` item.
/// When you set the item back to `nil`, Quick Look dismisses the preview.
///
/// Upon dismissal by the user, Quick Look automatically sets the item binding to `nil`.
/// Quick Look displays the preview when a non-`nil` item is set.
/// Set `item` to `nil` to dismiss the preview.
///
/// - Parameters:
/// - item: A <doc://com.apple.documentation/documentation/SwiftUI/Binding> to a URL that should be previewed.
///
/// - Returns: A view that presents the preview of the contents of the URL.
public func quickLookPreview(_ item: Binding<URL?>) -> some View {
#if os(iOS) || os(macOS)
content.background(QuicklookSheet(selection: item, items: [item.wrappedValue].compactMap { $0 }))
#else
content
#endif
}
}
#if os(macOS)
import QuickLookUI
private struct QuicklookSheet<Items>: NSViewControllerRepresentable
where Items: RandomAccessCollection, Items.Element == URL {
let selection: Binding<Items.Element?>
let items: Items
func makeNSViewController(context _: Context) -> PreviewController<Items> {
.init(selection: selection, in: items)
}
func updateNSViewController(_ controller: PreviewController<Items>, context _: Context) {
controller.selection = selection
controller.items = items
}
}
#elseif os(iOS)
private struct QuicklookSheet<Items>: UIViewControllerRepresentable
where Items: RandomAccessCollection, Items.Element == URL {
let selection: Binding<Items.Element?>
let items: Items
func makeUIViewController(context _: Context) -> PreviewController<Items> {
.init(selection: selection, in: items)
}
func updateUIViewController(_ controller: PreviewController<Items>, context _: Context) {
controller.items = items
controller.selection = selection
}
}
#endif

View File

@ -0,0 +1,189 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 15)
@available(macOS, deprecated: 12)
@available(tvOS, deprecated: 15)
@available(watchOS, deprecated: 8)
extension Backport where Wrapped: View {
/// Marks this view as refreshable.
///
/// Apply this modifier to a view to set the ``EnvironmentValues/refresh``
/// value in the view's environment to a ``RefreshAction`` instance that
/// uses the specified `action` as its handler. Views that detect the
/// presence of the instance can change their appearance to provide a
/// way for the user to execute the handler.
///
/// You can add refresh capability to your own views as well. For
/// information on how to do that, see ``RefreshAction``.
///
/// - Parameters:
/// - action: An asynchronous handler that SwiftUI executes when the
/// user requests a refresh. Use this handler to initiate
/// an update of model data displayed in the modified view. Use
/// `await` in front of any asynchronous calls inside the handler.
/// - Returns: A view with a new refresh action in its environment.
public func refreshable(action: @escaping @Sendable () async -> Void) -> some View {
#if os(iOS)
content
.environment(\.backportRefresh, Backport<Any>.RefreshAction(action))
.inspect { inspector in
inspector.sibling(ofType: UITableView.self)
} customize: { scrollView in
guard scrollView.refreshControl == nil else { return }
scrollView.refreshControl = RefreshControl {
await action()
}
}
#else
content
.environment(\.backportRefresh, Backport<Any>.RefreshAction(action))
#endif
}
}
#if os(iOS)
private final class RefreshControl: UIRefreshControl {
var handler: (() async -> Void)?
init(_ handler: @escaping () async -> Void) {
super.init()
self.handler = { [weak self] in
Task { [weak self] in
await handler()
self?.endRefreshing()
}
}
addTarget(self, action: #selector(update), for: .valueChanged)
}
@MainActor
override func endRefreshing() {
super.endRefreshing()
}
@objc private func update() {
Task { await handler?() }
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
#endif
@available(iOS, deprecated: 15)
@available(macOS, deprecated: 12)
@available(tvOS, deprecated: 15)
@available(watchOS, deprecated: 8)
extension Backport where Wrapped == Any {
/// An action that initiates a refresh operation.
///
/// Unlike the official implementation, this backport does not affect any
/// view's like `List` to provide automatic pull-to-refresh behaviour.
///
/// You can use this to offer refresh capability in your custom views.
/// Read the ``EnvironmentValues/refresh`` environment value to get the
/// `RefreshAction` instance for a given ``Environment``. If you find
/// a non-`nil` value, change your view's appearance or behavior to offer
/// the refresh to the user, and call the instance to conduct the
/// refresh. You can call the refresh instance directly because it defines
/// a ``RefreshAction/callAsFunction()`` method that Swift calls
/// when you call the instance:
///
/// struct RefreshableView: View {
/// @Environment(\.refresh) private var refresh
///
/// var body: some View {
/// Button("Refresh") {
/// Task {
/// await refresh?()
/// }
/// }
/// .disabled(refresh == nil)
/// }
/// }
///
/// Be sure to call the handler asynchronously by preceding it
/// with `await`. Because the call is asynchronous, you can use
/// its lifetime to indicate progress to the user. For example,
/// you might reveal an indeterminate ``ProgressView`` before
/// calling the handler, and hide it when the handler completes.
///
/// If your code isn't already in an asynchronous context, create a
/// <doc://com.apple.documentation/documentation/Swift/Task> for the
/// method to run in. If you do this, consider adding a way for the
/// user to cancel the task. For more information, see
/// [Concurrency](https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html)
/// in *The Swift Programming Language*.
public struct RefreshAction {
private var action: () async -> Void
internal init(_ action: @escaping () async -> Void) {
self.action = action
}
public func callAsFunction() async {
await action()
}
}
}
private struct RefreshEnvironmentKey: EnvironmentKey {
static let defaultValue: Backport<Any>.RefreshAction? = nil
}
@available(iOS, deprecated: 15)
@available(macOS, deprecated: 12)
@available(tvOS, deprecated: 15)
@available(watchOS, deprecated: 8)
extension EnvironmentValues {
/// An action that initiates a refresh operation.
///
/// Unlike the official implementation, this backport does not affect any
/// view's like `List` to provide automatic pull-to-refresh behaviour.
///
/// You can use this to offer refresh capability in your custom views.
/// Read the ``EnvironmentValues/refresh`` environment value to get the
/// `RefreshAction` instance for a given ``Environment``. If you find
/// a non-`nil` value, change your view's appearance or behavior to offer
/// the refresh to the user, and call the instance to conduct the
/// refresh. You can call the refresh instance directly because it defines
/// a ``RefreshAction/callAsFunction()`` method that Swift calls
/// when you call the instance:
///
/// struct RefreshableView: View {
/// @Environment(\.refresh) private var refresh
///
/// var body: some View {
/// Button("Refresh") {
/// Task {
/// await refresh?()
/// }
/// }
/// .disabled(refresh == nil)
/// }
/// }
///
/// Be sure to call the handler asynchronously by preceding it
/// with `await`. Because the call is asynchronous, you can use
/// its lifetime to indicate progress to the user. For example,
/// you might reveal an indeterminate ``ProgressView`` before
/// calling the handler, and hide it when the handler completes.
///
/// If your code isn't already in an asynchronous context, create a
/// <doc://com.apple.documentation/documentation/Swift/Task> for the
/// method to run in. If you do this, consider adding a way for the
/// user to cancel the task. For more information, see
/// [Concurrency](https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html)
/// in *The Swift Programming Language*.
public var backportRefresh: Backport<Any>.RefreshAction? {
get { self[RefreshEnvironmentKey.self] }
set { self[RefreshEnvironmentKey.self] = newValue }
}
}

View File

@ -0,0 +1,41 @@
import StoreKit
import SwiftUI
#if os(iOS) || os(macOS)
extension EnvironmentValues {
/// An instance that tells StoreKit to request an App Store rating or review from the user, if appropriate.
/// Read the requestReview environment value to get an instance of this structure for a given Environment. Call the instance to tell StoreKit to ask the user to rate or review your app, if appropriate. You call the instance directly because it defines a callAsFunction() method that Swift calls when you call the instance.
///
/// Although you normally call this instance to request a review when it makes sense in the user experience flow of your app, the App Store policy governs the actual display of the rating and review request view. Because calling this instance may not present an alert, dont call it in response to a user action, such as a button tap.
///
/// > When you call this instance while your app is in development mode, the system always displays a rating and review request view so you can test the user interface and experience. This instance has no effect when you call it in an app that you distribute using TestFlight.
@MainActor public var backportRequestReview: Backport<Any>.RequestReviewAction { .init() }
}
/// An instance that tells StoreKit to request an App Store rating or review from the user, if appropriate.
/// Read the requestReview environment value to get an instance of this structure for a given Environment. Call the instance to tell StoreKit to ask the user to rate or review your app, if appropriate. You call the instance directly because it defines a callAsFunction() method that Swift calls when you call the instance.
///
/// Although you normally call this instance to request a review when it makes sense in the user experience flow of your app, the App Store policy governs the actual display of the rating and review request view. Because calling this instance may not present an alert, dont call it in response to a user action, such as a button tap.
///
/// > When you call this instance while your app is in development mode, the system always displays a rating and review request view so you can test the user interface and experience. This instance has no effect when you call it in an app that you distribute using TestFlight.
///
@available(iOS, deprecated: 16)
@available(macOS, deprecated: 13)
extension Backport where Wrapped == Any {
@MainActor public struct RequestReviewAction {
public func callAsFunction() {
#if os(macOS)
SKStoreReviewController.requestReview()
#else
if #available(iOS 14, *) {
guard let scene = UIApplication.activeScene else { return }
SKStoreReviewController.requestReview(in: scene)
} else {
SKStoreReviewController.requestReview()
}
#endif
}
}
}
#endif

View File

@ -0,0 +1,54 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 15)
@available(tvOS, deprecated: 15)
@available(macOS, deprecated: 12)
@available(watchOS, deprecated: 8)
extension Backport where Wrapped == Any {
/// A container view that you can use to add hierarchy to certain collection views.
///
/// Use `Section` instances in views like ``List``, ``Picker``, and
/// ``Form`` to organize content into separate sections. Each section has
/// custom content that you provide on a per-instance basis. You can also
/// provide headers and footers for each section.
public struct Section<Parent: View, Content: View, Footer: View>: View {
@ViewBuilder let content: () -> Content
@ViewBuilder let header: () -> Parent
@ViewBuilder let footer: () -> Footer
public var body: some View {
SwiftUI.Section(
content: content,
header: header,
footer: footer
)
}
}
}
extension Backport.Section where Wrapped == Any, Parent == Text, Footer == EmptyView {
/// Creates a section with the provided section content.
/// - Parameters:
/// - titleKey: The key for the section's localized title, which describes
/// the contents of the section.
/// - content: The section's content.
public init(_ titleKey: LocalizedStringKey, @ViewBuilder content: @escaping () -> Content) {
header = { Text(titleKey) }
self.content = content
footer = { EmptyView() }
}
/// Creates a section with the provided section content.
/// - Parameters:
/// - title: A string that describes the contents of the section.
/// - content: The section's content.
public init<S>(_ title: S, @ViewBuilder content: @escaping () -> Content) where S: StringProtocol {
header = { Text(title) }
self.content = content
footer = { EmptyView() }
}
}

View File

@ -0,0 +1,148 @@
import Combine
import SwiftUI
@available(iOS, deprecated: 14.0)
@available(macOS, deprecated: 11.0)
@available(tvOS, deprecated: 14.0)
@available(watchOS, deprecated: 7.0)
extension Backport where Wrapped: ObservableObject {
/// A property wrapper type that instantiates an observable object.
///
/// Create a state object in a ``SwiftUI/View``, ``SwiftUI/App``, or
/// ``SwiftUI/Scene`` by applying the `@Backport.StateObject` attribute to a property
/// declaration and providing an initial value that conforms to the
/// <doc://com.apple.documentation/documentation/Combine/ObservableObject>
/// protocol:
///
/// @Backport.StateObject var model = DataModel()
///
/// SwiftUI creates a new instance of the object only once for each instance of
/// the structure that declares the object. When published properties of the
/// observable object change, SwiftUI updates the parts of any view that depend
/// on those properties:
///
/// Text(model.title) // Updates the view any time `title` changes.
///
/// You can pass the state object into a property that has the
/// ``SwiftUI/ObservedObject`` attribute. You can alternatively add the object
/// to the environment of a view hierarchy by applying the
/// ``SwiftUI/View/environmentObject(_:)`` modifier:
///
/// ContentView()
/// .environmentObject(model)
///
/// If you create an environment object as shown in the code above, you can
/// read the object inside `ContentView` or any of its descendants
/// using the ``SwiftUI/EnvironmentObject`` attribute:
///
/// @EnvironmentObject var model: DataModel
///
/// Get a ``SwiftUI/Binding`` to one of the state object's properties using the
/// `$` operator. Use a binding when you want to create a two-way connection to
/// one of the object's properties. For example, you can let a
/// ``SwiftUI/Toggle`` control a Boolean value called `isEnabled` stored in the
/// model:
///
/// Toggle("Enabled", isOn: $model.isEnabled)
@propertyWrapper public struct StateObject: DynamicProperty {
private final class Wrapper: ObservableObject {
private var subject = PassthroughSubject<Void, Never>()
var value: Wrapped? {
didSet {
cancellable = nil
cancellable = value?.objectWillChange
.sink { [subject] _ in subject.send() }
}
}
private var cancellable: AnyCancellable?
var objectWillChange: AnyPublisher<Void, Never> {
subject.eraseToAnyPublisher()
}
}
@State private var state = Wrapper()
@ObservedObject private var observedObject = Wrapper()
private var thunk: () -> Wrapped
/// The underlying value referenced by the state object.
///
/// The wrapped value property provides primary access to the value's data.
/// However, you don't access `wrappedValue` directly. Instead, use the
/// property variable created with the `@Backport.StateObject` attribute:
///
/// @Backport.StateObject var contact = Contact()
///
/// var body: some View {
/// Text(contact.name) // Accesses contact's wrapped value.
/// }
///
/// When you change a property of the wrapped value, you can access the new
/// value immediately. However, SwiftUI updates views displaying the value
/// asynchronously, so the user interface might not update immediately.
public var wrappedValue: Wrapped {
if let object = state.value {
return object
} else {
let object = thunk()
state.value = object
return object
}
}
/// A projection of the state object that creates bindings to its
/// properties.
///
/// Use the projected value to pass a binding value down a view hierarchy.
/// To get the projected value, prefix the property variable with `$`. For
/// example, you can get a binding to a model's `isEnabled` Boolean so that
/// a ``SwiftUI/Toggle`` view can control the value:
///
/// struct MyView: View {
/// @Backport.StateObject var model = DataModel()
///
/// var body: some View {
/// Toggle("Enabled", isOn: $model.isEnabled)
/// }
/// }
public var projectedValue: ObservedObject<Wrapped>.Wrapper {
ObservedObject(wrappedValue: wrappedValue).projectedValue
}
/// Creates a new state object with an initial wrapped value.
///
/// You dont call this initializer directly. Instead, declare a property
/// with the `@Backport.StateObject` attribute in a ``SwiftUI/View``,
/// ``SwiftUI/App``, or ``SwiftUI/Scene``, and provide an initial value:
///
/// struct MyView: View {
/// @Backport.StateObject var model = DataModel()
///
/// // ...
/// }
///
/// SwiftUI creates only one instance of the state object for each
/// container instance that you declare. In the code above, SwiftUI
/// creates `model` only the first time it initializes a particular instance
/// of `MyView`. On the other hand, each different instance of `MyView`
/// receives a distinct copy of the data model.
///
/// - Parameter thunk: An initial value for the state object.
public init(wrappedValue thunk: @autoclosure @escaping () -> Wrapped) {
self.thunk = thunk
}
public mutating func update() {
if state.value == nil {
state.value = thunk()
}
if observedObject.value !== state.value {
observedObject.value = state.value
}
}
}
}

View File

@ -0,0 +1,172 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 15.0)
@available(macOS, deprecated: 12.0)
@available(tvOS, deprecated: 15.0)
@available(watchOS, deprecated: 8.0)
extension Backport where Wrapped: View {
/// Adds an asynchronous task to perform when this view appears.
///
/// Use this modifier to perform an asynchronous task with a lifetime that
/// matches that of the modified view. If the task doesn't finish
/// before SwiftUI removes the view or the view changes identity, SwiftUI
/// cancels the task.
///
/// Use the `await` keyword inside the task to
/// wait for an asynchronous call to complete.
///
/// let url = URL(string: "https://example.com")!
/// @State private var message = "Loading..."
///
/// var body: some View {
/// Text(message)
/// .task {
/// do {
/// var receivedLines = [String]()
/// for try await line in url.lines {
/// receivedLines.append(line)
/// message = "Received \(receivedLines.count) lines"
/// }
/// } catch {
/// message = "Failed to load"
/// }
/// }
/// }
///
/// When each new line arrives, the body of the `for`-`await`-`in`
/// loop stores the line in an array of strings and updates the content of the
/// text view to report the latest line count.
///
/// - Parameters:
/// - priority: The task priority to use when creating the asynchronous
/// task. The default priority is `.userInitiated`
/// - action: A closure that SwiftUI calls as an asynchronous task
/// when the view appears. SwiftUI automatically cancels the task
/// if the view disappears before the action completes.
///
///
/// - Returns: A view that runs the specified action asynchronously when
/// the view appears.
@ViewBuilder
public func task(priority: TaskPriority = .userInitiated, _ action: @MainActor @escaping @Sendable () async -> Void)
-> some View
{
content.modifier(
TaskModifier(
id: 0,
priority: priority,
action: action
)
)
}
/// Adds a task to perform when this view appears or when a specified
/// value changes.
///
/// This method behaves like ``View/task(priority:_:)``, except that it also
/// cancels and recreates the task when a specified value changes. To detect
/// a change, the modifier tests whether a new value for the `id` parameter
/// equals the previous value. For this to work,
/// the value's type must conform to the `Equatable` protocol.
///
/// For example, if you define an equatable `Server` type that posts custom
/// notifications whenever its state changes --- for example, from _signed
/// out_ to _signed in_ --- you can use the task modifier to update
/// the contents of a ``Text`` view to reflect the state of the
/// currently selected server:
///
/// Text(status ?? "Signed Out")
/// .task(id: server) {
/// let sequence = NotificationCenter.default.notifications(
/// named: .didChangeStatus,
/// object: server)
/// for try await notification in sequence {
/// status = notification.userInfo["status"] as? String
/// }
/// }
///
/// Elsewhere, the server defines a custom `didUpdateStatus` notification:
///
/// extension NSNotification.Name {
/// static var didUpdateStatus: NSNotification.Name {
/// NSNotification.Name("didUpdateStatus")
/// }
/// }
///
/// The server then posts a notification of this type whenever its status
/// changes, like after the user signs in:
///
/// let notification = Notification(
/// name: .didUpdateStatus,
/// object: self,
/// userInfo: ["status": "Signed In"])
/// NotificationCenter.default.post(notification)
///
/// The task attached to the ``Text`` view gets and displays the status
/// value from the notification's user information dictionary. When the user
/// chooses a different server, SwiftUI cancels the task and creates a new
/// one, which then starts waiting for notifications from the new server.
///
/// - Parameters:
/// - id: The value to observe for changes. The value must conform
/// to the `Equatable` protocol.
/// - priority: The task priority to use when creating the asynchronous
/// task. The default priority is `.userInitiated`
/// - action: A closure that SwiftUI calls as an asynchronous task
/// when the view appears. SwiftUI automatically cancels the task
/// if the view disappears before the action completes. If the
/// `id` value changes, SwiftUI cancels and restarts the task.
///
/// - Returns: A view that runs the specified action asynchronously when
/// the view appears, or restarts the task with the `id` value changes.
@ViewBuilder
public func task<T: Equatable>(
id: T, priority: TaskPriority = .userInitiated, _ action: @MainActor @escaping @Sendable () async -> Void
) -> some View {
content.modifier(
TaskModifier(
id: id,
priority: priority,
action: action
)
)
}
}
private struct TaskModifier<ID: Equatable>: ViewModifier {
var id: ID
var priority: TaskPriority
var action: () async -> Void
@State private var task: Task<Void, Never>?
init(id: ID, priority: TaskPriority, action: @MainActor @escaping () async -> Void) {
self.id = id
self.priority = priority
self.action = action
}
func body(content: Content) -> some View {
content
.backport.onChange(of: id) { _ in
task?.cancel()
task = Task(priority: priority) {
await action()
}
}
.onAppear {
task?.cancel()
task = Task(priority: priority) {
await action()
}
}
.onDisappear {
task?.cancel()
task = nil
}
}
}

View File

@ -0,0 +1,33 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
extension Backport where Wrapped == AnyTransition {
/// Creates a transition that when added to a view will animate the views insertion by moving it in from the specified edge while fading it in, and animate its removal by moving it out towards the opposite edge and fading it out.
/// - Parameter edge: the edge from which the view will be animated in.
/// - Returns: A transition that animates a view by moving and fading it.
@available(iOS, deprecated: 16.0)
@available(watchOS, deprecated: 9.0)
@available(macOS, deprecated: 13.0)
@available(tvOS, deprecated: 16.0)
public func push(from edge: Edge) -> AnyTransition {
var oppositeEdge: Edge
switch edge {
case .top:
oppositeEdge = .bottom
case .leading:
oppositeEdge = .trailing
case .bottom:
oppositeEdge = .top
case .trailing:
oppositeEdge = .leading
}
return .asymmetric(
insertion: .move(edge: edge),
removal: .move(edge: oppositeEdge)
).combined(with: .opacity)
}
}

View File

@ -0,0 +1,166 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import Foundation
@available(iOS, deprecated: 15.0)
@available(macOS, deprecated: 12.0)
@available(tvOS, deprecated: 15.0)
@available(watchOS, deprecated: 8.0)
extension Backport where Wrapped: URLSession {
/// Start a data task with a URL using async/await.
/// - parameter url: The URL to send a request to.
/// - returns: A tuple containing the binary `Data` that was downloaded,
/// as well as a `URLResponse` representing the server's response.
/// - throws: Any error encountered while performing the data task.
public func data(from url: URL) async throws -> (Data, URLResponse) {
try await data(for: URLRequest(url: url))
}
/// Start a data task with a `URLRequest` using async/await.
/// - parameter request: The `URLRequest` that the data task should perform.
/// - returns: A tuple containing the binary `Data` that was downloaded,
/// as well as a `URLResponse` representing the server's response.
/// - throws: Any error encountered while performing the data task.
public func data(for request: URLRequest) async throws -> (Data, URLResponse) {
let sessionTask = URLSessionTaskActor()
return try await withTaskCancellationHandler {
Task { await sessionTask.cancel() }
} operation: {
try await withCheckedThrowingContinuation { continuation in
Task {
await sessionTask.start(
content.dataTask(with: request) { data, response, error in
guard let data = data, let response = response else {
let error = error ?? URLError(.badServerResponse)
continuation.resume(throwing: error)
return
}
continuation.resume(returning: (data, response))
})
}
}
}
}
public func upload(for request: URLRequest, fromFile fileURL: URL) async throws -> (Data, URLResponse) {
let sessionTask = URLSessionTaskActor()
return try await withTaskCancellationHandler {
Task { await sessionTask.cancel() }
} operation: {
try await withCheckedThrowingContinuation { continuation in
Task {
await sessionTask.start(
content.uploadTask(with: request, fromFile: fileURL) { data, response, error in
guard let data = data, let response = response else {
let error = error ?? URLError(.badServerResponse)
return continuation.resume(throwing: error)
}
continuation.resume(returning: (data, response))
})
}
}
}
}
public func upload(for request: URLRequest, from bodyData: Data) async throws -> (Data, URLResponse) {
let sessionTask = URLSessionTaskActor()
return try await withTaskCancellationHandler {
Task { await sessionTask.cancel() }
} operation: {
try await withCheckedThrowingContinuation { continuation in
Task {
await sessionTask.start(
content.uploadTask(with: request, from: bodyData) { data, response, error in
guard let data = data, let response = response else {
let error = error ?? URLError(.badServerResponse)
return continuation.resume(throwing: error)
}
continuation.resume(returning: (data, response))
})
}
}
}
}
public func download(for request: URLRequest) async throws -> (URL, URLResponse) {
let sessionTask = URLSessionTaskActor()
return try await withTaskCancellationHandler {
Task { await sessionTask.cancel() }
} operation: {
try await withCheckedThrowingContinuation { continuation in
Task {
await sessionTask.start(
content.downloadTask(with: request) { data, response, error in
guard let data = data, let response = response else {
let error = error ?? URLError(.badServerResponse)
return continuation.resume(throwing: error)
}
continuation.resume(returning: (data, response))
})
}
}
}
}
public func download(from url: URL) async throws -> (URL, URLResponse) {
let sessionTask = URLSessionTaskActor()
return try await withTaskCancellationHandler {
Task { await sessionTask.cancel() }
} operation: {
try await withCheckedThrowingContinuation { continuation in
Task {
await sessionTask.start(
content.downloadTask(with: url) { data, response, error in
guard let data = data, let response = response else {
let error = error ?? URLError(.badServerResponse)
return continuation.resume(throwing: error)
}
continuation.resume(returning: (data, response))
})
}
}
}
}
public func download(resumeFrom resumeData: Data) async throws -> (URL, URLResponse) {
let sessionTask = URLSessionTaskActor()
return try await withTaskCancellationHandler {
Task { await sessionTask.cancel() }
} operation: {
try await withCheckedThrowingContinuation { continuation in
Task {
await sessionTask.start(
content.downloadTask(withResumeData: resumeData) { data, response, error in
guard let data = data, let response = response else {
let error = error ?? URLError(.badServerResponse)
return continuation.resume(throwing: error)
}
continuation.resume(returning: (data, response))
})
}
}
}
}
}
private actor URLSessionTaskActor {
weak var task: URLSessionTask?
func start(_ task: URLSessionTask) {
self.task = task
task.resume()
}
func cancel() {
task?.cancel()
}
}

View File

@ -0,0 +1,41 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 15)
@available(macOS, deprecated: 12)
@available(tvOS, deprecated: 15)
@available(watchOS, deprecated: 8)
extension Backport where Wrapped == Any {
public enum Visibility: Hashable, CaseIterable {
/// The element may be visible or hidden depending on the policies of the
/// component accepting the visibility configuration.
///
/// For example, some components employ different automatic behavior
/// depending on factors including the platform, the surrounding container,
/// user settings, etc.
case automatic
/// The element may be visible.
///
/// Some APIs may use this value to represent a hint or preference, rather
/// than a mandatory assertion. For example, setting list row separator
/// visibility to `visible` using the
/// ``View/listRowSeparator(_:edges:)`` modifier may not always
/// result in any visible separators, especially for list styles that do not
/// include separators as part of their design.
case visible
/// The element may be hidden.
///
/// Some APIs may use this value to represent a hint or preference, rather
/// than a mandatory assertion. For example, setting confirmation dialog
/// title visibility to `hidden` using the
/// ``View/confirmationDialog(_:isPresented:titleVisibility:actions:)-87n66``
/// modifier may not always hide the dialog title, which is required on
/// some platforms.
case hidden
}
}

View File

@ -0,0 +1,296 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(tvOS, deprecated: 16)
@available(macOS, deprecated: 13)
@available(watchOS, deprecated: 9)
extension Backport where Wrapped: View {
/// Sets the available detents for the enclosing sheet.
///
/// By default, sheets support the ``PresentationDetent/large`` detent.
///
/// struct ContentView: View {
/// @State private var showSettings = false
///
/// var body: some View {
/// Button("View Settings") {
/// showSettings = true
/// }
/// .sheet(isPresented: $showSettings) {
/// SettingsView()
/// .presentationDetents([.medium, .large])
/// }
/// }
/// }
///
/// - Parameter detents: A set of supported detents for the sheet.
/// If you provide more than one detent, people can drag the sheet
/// to resize it.
@ViewBuilder
@available(iOS, introduced: 15, deprecated: 16, message: "Presentation detents are only supported in iOS 15+")
public func presentationDetents(_ detents: Set<Backport<Any>.PresentationDetent>) -> some View {
#if os(iOS)
content.background(Backport<Any>.Representable(detents: detents, selection: nil, largestUndimmed: .large))
#else
content
#endif
}
/// Sets the available detents for the enclosing sheet, giving you
/// programmatic control of the currently selected detent.
///
/// By default, sheets support the ``PresentationDetent/large`` detent.
///
/// struct ContentView: View {
/// @State private var showSettings = false
/// @State private var settingsDetent = PresentationDetent.medium
///
/// var body: some View {
/// Button("View Settings") {
/// showSettings = true
/// }
/// .sheet(isPresented: $showSettings) {
/// SettingsView()
/// .presentationDetents:(
/// [.medium, .large],
/// selection: $settingsDetent
/// )
/// }
/// }
/// }
///
/// - Parameters:
/// - detents: A set of supported detents for the sheet.
/// If you provide more that one detent, people can drag the sheet
/// to resize it.
/// - selection: A ``Binding`` to the currently selected detent.
/// Ensure that the value matches one of the detents that you
/// provide for the `detents` parameter.
@ViewBuilder
@available(iOS, introduced: 15, deprecated: 16, message: "Presentation detents are only supported in iOS 15+")
public func presentationDetents(
_ detents: Set<Backport<Any>.PresentationDetent>, selection: Binding<Backport<Any>.PresentationDetent>
) -> some View {
#if os(iOS)
content.background(Backport<Any>.Representable(detents: detents, selection: selection, largestUndimmed: .large))
#else
content
#endif
}
/// Sets the available detents for the enclosing sheet, giving you
/// programmatic control of the currently selected detent.
///
/// By default, sheets support the ``PresentationDetent/large`` detent.
///
/// struct ContentView: View {
/// @State private var showSettings = false
/// @State private var settingsDetent = PresentationDetent.medium
///
/// var body: some View {
/// Button("View Settings") {
/// showSettings = true
/// }
/// .sheet(isPresented: $showSettings) {
/// SettingsView()
/// .presentationDetents:(
/// [.medium, .large],
/// selection: $settingsDetent
/// )
/// }
/// }
/// }
///
/// - Parameters:
/// - detents: A set of supported detents for the sheet.
/// If you provide more that one detent, people can drag the sheet
/// to resize it.
/// - selection: A ``Binding`` to the currently selected detent.
/// Ensure that the value matches one of the detents that you
/// provide for the `detents` parameter.
@ViewBuilder
@available(iOS, introduced: 15, deprecated: 16, message: "Presentation detents are only supported in iOS 15+")
public func presentationDetents(
_ detents: Set<Backport<Any>.PresentationDetent>, selection: Binding<Backport<Any>.PresentationDetent>,
largestUndimmedDetent: Backport<Any>.PresentationDetent? = nil
) -> some View {
#if os(iOS)
content.background(
Backport<Any>.Representable(detents: detents, selection: selection, largestUndimmed: largestUndimmedDetent))
#else
content
#endif
}
}
@available(iOS, deprecated: 16)
@available(tvOS, deprecated: 16)
@available(macOS, deprecated: 13)
@available(watchOS, deprecated: 9)
extension Backport where Wrapped == Any {
/// A type that represents a height where a sheet naturally rests.
public struct PresentationDetent: Hashable, Comparable {
public struct Identifier: RawRepresentable, Hashable {
public var rawValue: String
public init(rawValue: String) {
self.rawValue = rawValue
}
public static var medium: Identifier {
.init(rawValue: "com.apple.UIKit.medium")
}
public static var large: Identifier {
.init(rawValue: "com.apple.UIKit.large")
}
}
public let id: Identifier
/// The system detent for a sheet that's approximately half the height of
/// the screen, and is inactive in compact height.
public static var medium: PresentationDetent {
.init(id: .medium)
}
/// The system detent for a sheet at full height.
public static var large: PresentationDetent {
.init(id: .large)
}
fileprivate static var none: PresentationDetent {
.init(id: .init(rawValue: ""))
}
public static func < (lhs: PresentationDetent, rhs: PresentationDetent) -> Bool {
switch (lhs, rhs) {
case (.large, .medium):
return false
default:
return true
}
}
}
}
#if os(iOS)
@available(iOS 15, *)
extension Backport where Wrapped == Any {
fileprivate struct Representable: UIViewControllerRepresentable {
let detents: Set<Backport<Any>.PresentationDetent>
let selection: Binding<Backport<Any>.PresentationDetent>?
let largestUndimmed: Backport<Any>.PresentationDetent?
func makeUIViewController(context _: Context) -> Backport.Representable.Controller {
Controller(detents: detents, selection: selection, largestUndimmed: largestUndimmed)
}
func updateUIViewController(_ controller: Backport.Representable.Controller, context _: Context) {
controller.update(detents: detents, selection: selection, largestUndimmed: largestUndimmed)
}
}
}
@available(iOS 15, *)
extension Backport.Representable {
fileprivate final class Controller: UIViewController, UISheetPresentationControllerDelegate {
var detents: Set<Backport<Any>.PresentationDetent>
var selection: Binding<Backport<Any>.PresentationDetent>?
var largestUndimmed: Backport<Any>.PresentationDetent?
weak var _delegate: UISheetPresentationControllerDelegate?
init(
detents: Set<Backport<Any>.PresentationDetent>, selection: Binding<Backport<Any>.PresentationDetent>?,
largestUndimmed: Backport<Any>.PresentationDetent?
) {
self.detents = detents
self.selection = selection
self.largestUndimmed = largestUndimmed
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func willMove(toParent parent: UIViewController?) {
super.willMove(toParent: parent)
if let controller = parent?.sheetPresentationController {
if controller.delegate !== self {
_delegate = controller.delegate
controller.delegate = self
}
}
update(detents: detents, selection: selection, largestUndimmed: largestUndimmed)
}
func update(
detents: Set<Backport<Any>.PresentationDetent>, selection: Binding<Backport<Any>.PresentationDetent>?,
largestUndimmed: Backport<Any>.PresentationDetent?
) {
self.detents = detents
self.selection = selection
self.largestUndimmed = largestUndimmed
if let controller = parent?.sheetPresentationController {
controller.animateChanges {
controller.detents = detents.sorted().map {
switch $0 {
case .medium:
return .medium()
default:
return .large()
}
}
controller.largestUndimmedDetentIdentifier = largestUndimmed.flatMap {
.init(rawValue: $0.id.rawValue)
}
if let selection = selection {
controller.selectedDetentIdentifier = .init(selection.wrappedValue.id.rawValue)
}
controller.prefersScrollingExpandsWhenScrolledToEdge = true
}
UIView.animate(withDuration: 0.25) {
if let undimmed = largestUndimmed {
controller.presentingViewController.view.tintAdjustmentMode =
(selection?.wrappedValue ?? .large) >= undimmed ? .automatic : .normal
} else {
controller.presentingViewController.view.tintAdjustmentMode = .automatic
}
}
}
}
func sheetPresentationControllerDidChangeSelectedDetentIdentifier(
_ sheetPresentationController: UISheetPresentationController
) {
guard
let selection = selection,
let id = sheetPresentationController.selectedDetentIdentifier?.rawValue,
selection.wrappedValue.id.rawValue != id
else { return }
selection.wrappedValue = .init(id: .init(rawValue: id))
}
override func responds(to aSelector: Selector!) -> Bool {
if super.responds(to: aSelector) { return true }
if _delegate?.responds(to: aSelector) ?? false { return true }
return false
}
override func forwardingTarget(for aSelector: Selector!) -> Any? {
if super.responds(to: aSelector) { return self }
return _delegate
}
}
}
#endif

View File

@ -0,0 +1,95 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 16)
@available(tvOS, deprecated: 16)
@available(macOS, deprecated: 13)
@available(watchOS, deprecated: 9)
extension Backport where Wrapped: View {
/// Sets the visibility of the drag indicator on top of a sheet.
///
/// You can show a drag indicator when it isn't apparent that a
/// sheet can resize or when the sheet can't dismiss interactively.
///
/// struct ContentView: View {
/// @State private var showSettings = false
///
/// var body: some View {
/// Button("View Settings") {
/// showSettings = true
/// }
/// .sheet(isPresented: $showSettings) {
/// SettingsView()
/// .presentationDetents:([.medium, .large])
/// .presentationDragIndicator(.visible)
/// }
/// }
/// }
///
/// - Parameter visibility: The preferred visibility of the drag indicator.
@ViewBuilder
public func presentationDragIndicator(_ visibility: Backport<Any>.Visibility) -> some View {
#if os(iOS)
if #available(iOS 15, *) {
content.background(Backport<Any>.Representable(visibility: visibility))
} else {
content
}
#else
content
#endif
}
}
#if os(iOS)
@available(iOS 15, *)
extension Backport where Wrapped == Any {
fileprivate struct Representable: UIViewControllerRepresentable {
let visibility: Backport<Any>.Visibility
func makeUIViewController(context _: Context) -> Backport.Representable.Controller {
Controller(visibility: visibility)
}
func updateUIViewController(_ controller: Backport.Representable.Controller, context _: Context) {
controller.update(visibility: visibility)
}
}
}
@available(iOS 15, *)
extension Backport.Representable {
fileprivate final class Controller: UIViewController {
var visibility: Backport<Any>.Visibility
init(visibility: Backport<Any>.Visibility) {
self.visibility = visibility
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func willMove(toParent parent: UIViewController?) {
super.willMove(toParent: parent)
update(visibility: visibility)
}
func update(visibility: Backport<Any>.Visibility) {
self.visibility = visibility
if let controller = parent?.sheetPresentationController {
controller.animateChanges {
controller.prefersGrabberVisible = visibility == .visible
controller.prefersScrollingExpandsWhenScrolledToEdge = true
}
}
}
}
}
#endif

View File

@ -0,0 +1,103 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 16)
@available(tvOS, deprecated: 16)
@available(macOS, deprecated: 13)
@available(watchOS, deprecated: 9)
extension Backport where Wrapped: View {
/// Removes dimming from detents higher (and including) the provided identifier
///
/// This has two affects on dentents higher than the identifier provided:
/// 1. Touches will passthrough to the views below the sheet.
/// 2. Touches will no longer dismiss the sheet automatically when tapping outside of the sheet.
///
/// ```
/// struct ContentView: View {
/// @State private var showSettings = false
///
/// var body: some View {
/// Button("View Settings") {
/// showSettings = true
/// }
/// .sheet(isPresented: $showSettings) {
/// SettingsView()
/// .presentationDetents:([.medium, .large])
/// .presentationUndimmed(from: .medium)
/// }
/// }
/// }
/// ```
///
/// - Parameter identifier: The identifier of the largest detent that is not dimmed.
@ViewBuilder
@available(
iOS, deprecated: 13, message: "Please use backport.presentationDetents(_:selection:largestUndimmedDetent:)"
)
public func presentationUndimmed(from identifier: Backport<Any>.PresentationDetent.Identifier?) -> some View {
#if os(iOS)
if #available(iOS 15, *) {
content.background(Backport<Any>.Representable(identifier: identifier))
} else {
content
}
#else
content
#endif
}
}
#if os(iOS)
@available(iOS 15, *)
extension Backport where Wrapped == Any {
fileprivate struct Representable: UIViewControllerRepresentable {
let identifier: Backport<Any>.PresentationDetent.Identifier?
func makeUIViewController(context _: Context) -> Backport.Representable.Controller {
Controller(identifier: identifier)
}
func updateUIViewController(_ controller: Backport.Representable.Controller, context _: Context) {
controller.update(identifier: identifier)
}
}
}
@available(iOS 15, *)
extension Backport.Representable {
fileprivate final class Controller: UIViewController {
var identifier: Backport<Any>.PresentationDetent.Identifier?
init(identifier: Backport<Any>.PresentationDetent.Identifier?) {
self.identifier = identifier
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func willMove(toParent parent: UIViewController?) {
super.willMove(toParent: parent)
update(identifier: identifier)
}
func update(identifier: Backport<Any>.PresentationDetent.Identifier?) {
self.identifier = identifier
if let controller = parent?.sheetPresentationController {
controller.animateChanges {
controller.presentingViewController.view.tintAdjustmentMode = .normal
controller.largestUndimmedDetentIdentifier = identifier.flatMap {
.init(rawValue: $0.rawValue)
}
}
}
}
}
}
#endif

View File

@ -0,0 +1,242 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
extension Backport where Wrapped: View {
/// Conditionally prevents interactive dismissal of a popover or a sheet.
///
/// Users can dismiss certain kinds of presentations using built-in
/// gestures. In particular, a user can dismiss a sheet by dragging it down,
/// or a popover by clicking or tapping outside of the presented view. Use
/// the `interactiveDismissDisabled(_:)` modifier to conditionally prevent
/// this kind of dismissal. You typically do this to prevent the user from
/// dismissing a presentation before providing needed data or completing
/// a required action.
///
/// For instance, suppose you have a view that displays a licensing
/// agreement that the user must acknowledge before continuing:
///
/// struct TermsOfService: View {
/// @Binding var areTermsAccepted: Bool
/// @Environment(\.backportDismiss) private var dismiss
///
/// var body: some View {
/// Form {
/// Text("License Agreement")
/// .font(.title)
/// Text("Terms and conditions go here.")
/// Button("Accept") {
/// areTermsAccepted = true
/// dismiss()
/// }
/// }
/// }
/// }
///
/// If you present this view in a sheet, the user can dismiss it by either
/// tapping the button --- which calls ``EnvironmentValues/backportDismiss``
/// from its `action` closure --- or by dragging the sheet down. To
/// ensure that the user accepts the terms by tapping the button,
/// disable interactive dismissal, conditioned on the `areTermsAccepted`
/// property:
///
/// struct ContentView: View {
/// @State private var isSheetPresented = false
/// @State private var areTermsAccepted = false
///
/// var body: some View {
/// Button("Use Service") {
/// isSheetPresented = true
/// }
/// .sheet(isPresented: $isSheetPresented) {
/// TermsOfService()
/// .backport.interactiveDismissDisabled(!areTermsAccepted)
/// }
/// }
/// }
///
/// You can apply the modifier to any view in the sheet's view hierarchy,
/// including to the sheet's top level view, as the example demonstrates,
/// or to any child view, like the ``Form`` or the Accept ``Button``.
///
/// The modifier has no effect on programmatic dismissal, which you can
/// invoke by updating the ``Binding`` that controls the presentation, or
/// by calling the environment's ``EnvironmentValues/backportDismiss`` action.
///
/// > This modifier currently has no effect on macOS, tvOS or watchOS.
///
/// - Parameter isDisabled: A Boolean value that indicates whether to
/// prevent nonprogrammatic dismissal of the containing view hierarchy
/// when presented in a sheet or popover.
@ViewBuilder
@available(iOS, deprecated: 16)
@available(tvOS, deprecated: 16)
@available(macOS, deprecated: 13)
@available(watchOS, deprecated: 9)
public func interactiveDismissDisabled(_ isDisabled: Bool = true) -> some View {
#if os(iOS)
if #available(iOS 15, *) {
content.background(Backport<Any>.Representable(isModal: isDisabled, onAttempt: nil))
} else {
content
}
#else
content
#endif
}
/// Conditionally prevents interactive dismissal of a popover or a sheet. In addition, provides fine-grained control over the dismissal
///
/// Users can dismiss certain kinds of presentations using built-in
/// gestures. In particular, a user can dismiss a sheet by dragging it down,
/// or a popover by clicking or tapping outside of the presented view. Use
/// the `interactiveDismissDisabled(_:)` modifier to conditionally prevent
/// this kind of dismissal. You typically do this to prevent the user from
/// dismissing a presentation before providing needed data or completing
/// a required action.
///
/// For instance, suppose you have a view that displays a licensing
/// agreement that the user must acknowledge before continuing:
///
/// struct TermsOfService: View {
/// @Binding var areTermsAccepted: Bool
/// @Environment(\.backportDismiss) private var dismiss
///
/// var body: some View {
/// Form {
/// Text("License Agreement")
/// .font(.title)
/// Text("Terms and conditions go here.")
/// Button("Accept") {
/// areTermsAccepted = true
/// dismiss()
/// }
/// }
/// }
/// }
///
/// If you present this view in a sheet, the user can dismiss it by either
/// tapping the button --- which calls ``EnvironmentValues/backportDismiss``
/// from its `action` closure --- or by dragging the sheet down. To
/// ensure that the user accepts the terms by tapping the button,
/// disable interactive dismissal, conditioned on the `areTermsAccepted`
/// property:
///
/// struct ContentView: View {
/// @State private var isSheetPresented = false
/// @State private var areTermsAccepted = false
///
/// var body: some View {
/// Button("Use Service") {
/// isSheetPresented = true
/// }
/// .sheet(isPresented: $isSheetPresented) {
/// TermsOfService()
/// .backport.interactiveDismissDisabled(!areTermsAccepted)
/// }
/// }
/// }
///
/// You can apply the modifier to any view in the sheet's view hierarchy,
/// including to the sheet's top level view, as the example demonstrates,
/// or to any child view, like the ``Form`` or the Accept ``Button``.
///
/// The modifier has no effect on programmatic dismissal, which you can
/// invoke by updating the ``Binding`` that controls the presentation, or
/// by calling the environment's ``EnvironmentValues/backportDismiss`` action.
///
/// > This modifier currently has no effect on macOS, tvOS or watchOS.
///
/// - Parameter isDisabled: A Boolean value that indicates whether to
/// prevent nonprogrammatic dismissal of the containing view hierarchy
/// when presented in a sheet or popover.
/// - Parameter onAttempt: A closure that will be called when an interactive dismiss attempt occurs.
/// You can use this as an opportunity to present an confirmation or prompt to the user.
@ViewBuilder
public func interactiveDismissDisabled(_ isDisabled: Bool = true, onAttempt: @escaping () -> Void) -> some View {
#if os(iOS)
if #available(iOS 15, *) {
content.background(Backport<Any>.Representable(isModal: isDisabled, onAttempt: onAttempt))
} else {
content
}
#else
content
#endif
}
}
#if os(iOS)
extension Backport where Wrapped == Any {
fileprivate struct Representable: UIViewControllerRepresentable {
let isModal: Bool
let onAttempt: (() -> Void)?
func makeUIViewController(context _: Context) -> Backport.Representable.Controller {
Controller(isModal: isModal, onAttempt: onAttempt)
}
func updateUIViewController(_ controller: Backport.Representable.Controller, context _: Context) {
controller.update(isModal: isModal, onAttempt: onAttempt)
}
}
}
extension Backport.Representable {
fileprivate final class Controller: UIViewController, UIAdaptivePresentationControllerDelegate {
var isModal: Bool
var onAttempt: (() -> Void)?
weak var _delegate: UIAdaptivePresentationControllerDelegate?
init(isModal: Bool, onAttempt: (() -> Void)?) {
self.isModal = isModal
self.onAttempt = onAttempt
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func willMove(toParent parent: UIViewController?) {
super.willMove(toParent: parent)
if let controller = parent?.presentationController {
if controller.delegate !== self {
_delegate = controller.delegate
controller.delegate = self
}
}
update(isModal: isModal, onAttempt: onAttempt)
}
func update(isModal: Bool, onAttempt: (() -> Void)?) {
self.isModal = isModal
self.onAttempt = onAttempt
parent?.isModalInPresentation = isModal
}
func presentationControllerDidAttemptToDismiss(_: UIPresentationController) {
onAttempt?()
}
func presentationControllerShouldDismiss(_: UIPresentationController) -> Bool {
parent?.isModalInPresentation == false
}
override func responds(to aSelector: Selector!) -> Bool {
if super.responds(to: aSelector) { return true }
if _delegate?.responds(to: aSelector) ?? false { return true }
return false
}
override func forwardingTarget(for aSelector: Selector!) -> Any? {
if super.responds(to: aSelector) { return self }
return _delegate
}
}
}
#endif

View File

@ -0,0 +1,80 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 14)
@available(macOS, deprecated: 11)
@available(tvOS, deprecated: 14)
@available(watchOS, deprecated: 7)
extension Backport where Wrapped == Any {
/// A dynamic property that scales a numeric value.
@propertyWrapper
public struct ScaledMetric<Value>: DynamicProperty where Value: BinaryFloatingPoint {
@Environment(\.sizeCategory) var sizeCategory
private let baseValue: Value
#if os(iOS) || os(tvOS)
private let metrics: UIFontMetrics
#endif
public var wrappedValue: Value {
#if os(iOS) || os(tvOS)
let traits = UITraitCollection(traitsFrom: [
UITraitCollection(preferredContentSizeCategory: UIContentSizeCategory(sizeCategory: sizeCategory))
])
return Value(metrics.scaledValue(for: CGFloat(baseValue), compatibleWith: traits))
#else
return baseValue
#endif
}
#if os(iOS) || os(tvOS)
/// Creates the scaled metric with an unscaled value using the default scaling.
public init(baseValue: Value, metrics: UIFontMetrics) {
self.baseValue = baseValue
self.metrics = metrics
}
/// Creates the scaled metric with an unscaled value using the default scaling.
public init(wrappedValue: Value) {
self.init(baseValue: wrappedValue, metrics: UIFontMetrics(forTextStyle: .body))
}
/// Creates the scaled metric with an unscaled value and a text style to scale relative to.
public init(wrappedValue: Value, relativeTo textStyle: UIFont.TextStyle) {
self.init(baseValue: wrappedValue, metrics: UIFontMetrics(forTextStyle: textStyle))
}
#else
/// Creates the scaled metric with an unscaled value using the default scaling.
public init(wrappedValue: Value) {
baseValue = wrappedValue
}
#endif
}
}
#if os(iOS) || os(tvOS)
extension UIContentSizeCategory {
fileprivate init(sizeCategory: ContentSizeCategory?) {
switch sizeCategory {
case .accessibilityExtraExtraExtraLarge: self = .accessibilityExtraExtraExtraLarge
case .accessibilityExtraExtraLarge: self = .accessibilityExtraExtraLarge
case .accessibilityExtraLarge: self = .accessibilityExtraLarge
case .accessibilityLarge: self = .accessibilityLarge
case .accessibilityMedium: self = .accessibilityMedium
case .extraExtraExtraLarge: self = .extraExtraExtraLarge
case .extraExtraLarge: self = .extraExtraLarge
case .extraLarge: self = .extraLarge
case .extraSmall: self = .extraSmall
case .large: self = .large
case .medium: self = .medium
case .small: self = .small
default: self = .unspecified
}
}
}
#endif

View File

@ -0,0 +1,61 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 16)
@available(tvOS, deprecated: 16)
@available(macOS, deprecated: 13)
@available(watchOS, deprecated: 9)
extension EnvironmentValues {
/// The visiblity to apply to scroll indicators of any
/// vertically scrollable content.
public var backportVerticalScrollIndicatorVisibility: Backport<Any>.ScrollIndicatorVisibility {
get { self[BackportVerticalIndicatorKey.self] }
set { self[BackportVerticalIndicatorKey.self] = newValue }
}
/// The visibility to apply to scroll indicators of any
/// horizontally scrollable content.
public var backportHorizontalScrollIndicatorVisibility: Backport<Any>.ScrollIndicatorVisibility {
get { self[BackportHorizontalIndicatorKey.self] }
set { self[BackportHorizontalIndicatorKey.self] = newValue }
}
/// The way that scrollable content interacts with the software keyboard.
///
/// The default value is ``Backport.ScrollDismissesKeyboardMode.automatic``. Use the
/// ``View.backport.scrollDismissesKeyboard(_:)`` modifier to configure this
/// property.
public var backportScrollDismissesKeyboardMode: Backport<Any>.ScrollDismissesKeyboardMode {
get { self[BackportKeyboardDismissKey.self] }
set { self[BackportKeyboardDismissKey.self] = newValue }
}
/// A Boolean value that indicates whether any scroll views associated
/// with this environment allow scrolling to occur.
///
/// The default value is `true`. Use the ``View.backport.scrollDisabled(_:)``
/// modifier to configure this property.
public var backportIsScrollEnabled: Bool {
get { self[BackportScrollEnabledKey.self] }
set { self[BackportScrollEnabledKey.self] = newValue }
}
}
private struct BackportVerticalIndicatorKey: EnvironmentKey {
static var defaultValue: Backport<Any>.ScrollIndicatorVisibility = .automatic
}
private struct BackportHorizontalIndicatorKey: EnvironmentKey {
static var defaultValue: Backport<Any>.ScrollIndicatorVisibility = .automatic
}
private struct BackportKeyboardDismissKey: EnvironmentKey {
static var defaultValue: Backport<Any>.ScrollDismissesKeyboardMode = .automatic
}
private struct BackportScrollEnabledKey: EnvironmentKey {
static var defaultValue: Bool = true
}

View File

@ -0,0 +1,62 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 16)
@available(tvOS, deprecated: 16)
@available(macOS, deprecated: 13)
@available(watchOS, deprecated: 9)
extension Backport where Wrapped == Any {
/// The ways that scrollable content can interact with the software keyboard.
///
/// Use this type in a call to the ``View.backport.scrollDismissesKeyboard(_:)``
/// modifier to specify the dismissal behavior of scrollable views.
public struct ScrollDismissesKeyboardMode: Hashable, CustomStringConvertible {
internal enum DismissMode: Hashable {
case automatic
case immediately
case interactively
case never
}
let dismissMode: DismissMode
#if os(iOS)
var scrollViewDismissMode: UIScrollView.KeyboardDismissMode {
switch dismissMode {
case .automatic: return .none
case .immediately: return .onDrag
case .interactively: return .interactive
case .never: return .none
}
}
#endif
public var description: String {
String(describing: dismissMode)
}
/// Determine the mode automatically based on the surrounding context.
///
/// By default, a ``TextEditor`` is interactive while a ``List``
/// of scrollable content always dismiss the keyboard on a scroll
public static var automatic: Self { .init(dismissMode: .automatic) }
/// Dismiss the keyboard as soon as scrolling starts.
public static var immediately: Self { .init(dismissMode: .immediately) }
/// Enable people to interactively dismiss the keyboard as part of the
/// scroll operation.
///
/// The software keyboard's position tracks the gesture that drives the
/// scroll operation if the gesture crosses into the keyboard's area of the
/// display. People can dismiss the keyboard by scrolling it off the
/// display, or reverse the direction of the scroll to cancel the dismissal.
public static var interactively: Self { .init(dismissMode: .interactively) }
/// Never dismiss the keyboard automatically as a result of scrolling.
public static var never: Self { .init(dismissMode: .never) }
}
}

View File

@ -0,0 +1,70 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 16)
@available(tvOS, deprecated: 16)
@available(macOS, deprecated: 13)
@available(watchOS, deprecated: 9)
extension Backport where Wrapped: View {
/// Disables or enables scrolling in scrollable views.
///
/// Use this modifier to control whether a ``ScrollView`` can scroll:
///
/// @State private var isScrollDisabled = false
///
/// var body: some View {
/// ScrollView {
/// VStack {
/// Toggle("Disable", isOn: $isScrollDisabled)
/// MyContent()
/// }
/// }
/// .backport.scrollDisabled(isScrollDisabled)
/// }
///
/// SwiftUI passes the disabled property through the environment, which
/// means you can use this modifier to disable scrolling for all scroll
/// views within a view hierarchy. In the following example, the modifier
/// affects both scroll views:
///
/// ScrollView {
/// ForEach(rows) { row in
/// ScrollView(.horizontal) {
/// RowContent(row)
/// }
/// }
/// }
/// .backport.scrollDisabled(true)
///
/// You can also use this modifier to disable scrolling for other kinds
/// of scrollable views, like a ``List`` or a ``TextEditor``.
///
/// - Parameter disabled: A Boolean that indicates whether scrolling is
/// disabled.
public func scrollDisabled(_ disabled: Bool) -> some View {
#if os(iOS)
content
.environment(\.backportIsScrollEnabled, !disabled)
.inspect { inspector in
#if os(iOS)
inspector.sibling(ofType: UIScrollView.self)
#elseif os(macOS)
inspector.sibling(ofType: NSScrollView.self)
#endif
} customize: { scrollView in
#if os(iOS)
scrollView.isScrollEnabled = !disabled
#elseif os(macOS)
scrollView.hasHorizontalScroller = false
scrollView.hasVerticalScroller = false
#endif
}
#else
content
.environment(\.backportIsScrollEnabled, !disabled)
#endif
}
}

View File

@ -0,0 +1,57 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 16)
@available(tvOS, deprecated: 16)
@available(macOS, deprecated: 13)
@available(watchOS, deprecated: 9)
extension Backport where Wrapped == Any {
/// The visibility of scroll indicators of a UI element.
///
/// Pass a value of this type to the ``View.backport.scrollIndicators(_:axes:)`` method
/// to specify the preferred scroll indicator visibility of a view hierarchy.
public struct ScrollIndicatorVisibility: Hashable, CustomStringConvertible {
internal enum IndicatorVisibility: Hashable {
case automatic
case visible
case hidden
}
let visibility: Backport.Visibility
var scrollViewVisible: Bool {
visibility != .hidden
}
public var description: String {
String(describing: visibility)
}
/// Scroll indicator visibility depends on the
/// policies of the component accepting the visibility configuration.
public static var automatic: ScrollIndicatorVisibility {
.init(visibility: .automatic)
}
/// Show the scroll indicators.
///
/// The actual visibility of the indicators depends on platform
/// conventions like auto-hiding behaviors in iOS or user preference
/// behaviors in macOS.
public static var visible: ScrollIndicatorVisibility {
.init(visibility: .visible)
}
/// Hide the scroll indicators.
///
/// By default, scroll views in macOS show indicators when a
/// mouse is connected. Use ``never`` to indicate
/// a stronger preference that can override this behavior.
public static var hidden: ScrollIndicatorVisibility {
.init(visibility: .hidden)
}
}
}

View File

@ -0,0 +1,75 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 16)
@available(tvOS, deprecated: 16)
@available(macOS, deprecated: 13)
@available(watchOS, deprecated: 9)
extension Backport where Wrapped: View {
/// Sets the visibility of scroll indicators within this view.
///
/// Use this modifier to hide or show scroll indicators on scrollable
/// content in views like a ``ScrollView``, ``List``, or ``TextEditor``.
/// This modifier applies the prefered visibility to any
/// scrollable content within a view hierarchy.
///
/// ScrollView {
/// VStack(alignment: .leading) {
/// ForEach(0..<100) {
/// Text("Row \($0)")
/// }
/// }
/// }
/// .backport.scrollIndicators(.hidden)
///
/// Use the ``Backport.ScrollIndicatorVisibility.hidden`` value to indicate that you
/// prefer that views never show scroll indicators along a given axis.
/// Use ``Backport.ScrollIndicatorVisibility.visible`` when you prefer that
/// views show scroll indicators. Depending on platform conventions,
/// visible scroll indicators might only appear while scrolling. Pass
/// ``Backport.ScrollIndicatorVisibility.automatic`` to allow views to
/// decide whether or not to show their indicators.
///
/// - Parameters:
/// - visibility: The visibility to apply to scrollable views.
/// - axes: The axes of scrollable views that the visibility applies to.
///
/// - Returns: A view with the specified scroll indicator visibility.
public func scrollIndicators(
_ visibility: Backport<Any>.ScrollIndicatorVisibility, axes: Axis.Set = [.vertical, .horizontal]
) -> some View {
#if os(iOS)
content
.environment(
\.backportHorizontalScrollIndicatorVisibility, axes.contains(.horizontal) ? visibility : .automatic
)
.environment(\.backportVerticalScrollIndicatorVisibility, axes.contains(.vertical) ? visibility : .automatic)
.inspect { inspector in
#if os(iOS)
inspector.sibling(ofType: UIScrollView.self)
#else
inspector.sourceView
#endif
} customize: { scrollView in
#if os(iOS)
if axes.contains(.horizontal) {
scrollView.showsHorizontalScrollIndicator = visibility.scrollViewVisible
}
if axes.contains(.vertical) {
scrollView.showsVerticalScrollIndicator = visibility.scrollViewVisible
}
#endif
}
#else
content
.environment(
\.backportHorizontalScrollIndicatorVisibility, axes.contains(.horizontal) ? visibility : .automatic
)
.environment(\.backportVerticalScrollIndicatorVisibility, axes.contains(.vertical) ? visibility : .automatic)
#endif
}
}

View File

@ -0,0 +1,67 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
@available(iOS, deprecated: 16)
@available(tvOS, deprecated: 16)
@available(macOS, deprecated: 13)
@available(watchOS, deprecated: 9)
extension Backport where Wrapped: View {
/// Configures the behavior in which scrollable content interacts with
/// the software keyboard.
///
/// You use this modifier to customize how scrollable content interacts
/// with the software keyboard. For example, you can specify a value of
/// ``ScrollDismissesKeyboardMode/immediately`` to indicate that you
/// would like scrollable content to immediately dismiss the keyboard if
/// present when a scroll drag gesture begins.
///
/// @State var text = ""
///
/// ScrollView {
/// TextField("Prompt", text: $text)
/// ForEach(0 ..< 50) { index in
/// Text("\(index)")
/// .padding()
/// }
/// }
/// .scrollDismissesKeyboard(.immediately)
///
/// You can also use this modifier to customize the keyboard dismissal
/// behavior for other kinds of scrollable views, like a ``List`` or a
/// ``TextEditor``.
///
/// By default, a ``TextEditor`` is interactive while other kinds
/// of scrollable content always dismiss the keyboard on a scroll
/// when linked against iOS 16 or later. Pass a value of
/// ``ScrollDismissesKeyboardMode/never`` to indicate that scrollable
/// content should never automatically dismiss the keyboard.
///
/// - Parameter mode: The keyboard dismissal mode that scrollable content
/// uses.
///
/// - Returns: A view that uses the specified keyboard dismissal mode.
public func scrollDismissesKeyboard(_ mode: Backport<Any>.ScrollDismissesKeyboardMode) -> some View {
#if os(iOS)
content
.environment(\.backportScrollDismissesKeyboardMode, mode)
.inspect { inspector in
#if os(iOS)
inspector.sibling(ofType: UIScrollView.self)
#else
inspector.sourceView
#endif
} customize: { scrollView in
#if os(iOS)
guard scrollView.keyboardDismissMode != mode.scrollViewDismissMode else { return }
scrollView.keyboardDismissMode = mode.scrollViewDismissMode
#endif
}
#else
content
.environment(\.backportScrollDismissesKeyboardMode, mode)
#endif
}
}

View File

@ -0,0 +1,76 @@
import ObjectiveC
import SwiftUI
#if os(iOS) || os(tvOS)
extension UICollectionViewCell {
private static var configuredViewAssociatedKey: Void?
fileprivate var configuredView: UIView? {
get { objc_getAssociatedObject(self, &Self.configuredViewAssociatedKey) as? UIView }
set { objc_setAssociatedObject(self, &Self.configuredViewAssociatedKey, newValue, .OBJC_ASSOCIATION_ASSIGN) }
}
}
@available(iOS, deprecated: 14)
@available(tvOS, deprecated: 14)
@available(macOS, unavailable)
@available(watchOS, unavailable)
extension Backport where Wrapped: UICollectionViewCell {
/// The current content configuration of the cell.
///
/// Setting a content configuration replaces the existing contentView of the
/// cell with a new content view instance from the configuration.
public var contentConfiguration: BackportUIContentConfiguration? {
get { nil } // we can't really support anything here, so for now we'll return nil
nonmutating set {
content.configuredView?.removeFromSuperview()
guard let configuration = newValue else { return }
let contentView = content.contentView
let configuredView = configuration.makeContentView()
configuredView.translatesAutoresizingMaskIntoConstraints = false
content.clipsToBounds = false
contentView.clipsToBounds = false
contentView.preservesSuperviewLayoutMargins = false
contentView.addSubview(configuredView)
let insets =
Mirror(reflecting: configuration)
.children.first(where: { $0.label == "insets" })?.value as? ProposedInsets
?? .unspecified
insets.top.flatMap { contentView.directionalLayoutMargins.top = $0 }
insets.bottom.flatMap { contentView.directionalLayoutMargins.bottom = $0 }
insets.leading.flatMap { contentView.directionalLayoutMargins.leading = $0 }
insets.trailing.flatMap { contentView.directionalLayoutMargins.trailing = $0 }
NSLayoutConstraint.activate([
configuredView.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
configuredView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
configuredView.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor),
configuredView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
])
var background: AnyView? {
Mirror(reflecting: configuration)
.children.first(where: { $0.label == "background" })?.value as? AnyView
}
background.flatMap {
let host = UIHostingController(rootView: $0, ignoreSafeArea: true)
content.backgroundView = host.view
}
background.flatMap {
let host = UIHostingController(rootView: $0, ignoreSafeArea: true)
content.selectedBackgroundView = host.view
}
content.configuredView = configuredView
}
}
}
#endif

View File

@ -0,0 +1,76 @@
import ObjectiveC
import SwiftUI
#if os(iOS) || os(tvOS)
extension UITableViewCell {
private static var configuredViewAssociatedKey: Void?
fileprivate var configuredView: UIView? {
get { objc_getAssociatedObject(self, &Self.configuredViewAssociatedKey) as? UIView }
set { objc_setAssociatedObject(self, &Self.configuredViewAssociatedKey, newValue, .OBJC_ASSOCIATION_ASSIGN) }
}
}
@available(iOS, deprecated: 14)
@available(tvOS, deprecated: 14)
@available(macOS, unavailable)
@available(watchOS, unavailable)
extension Backport where Wrapped: UITableViewCell {
/// The current content configuration of the cell.
///
/// Setting a content configuration replaces the existing contentView of the
/// cell with a new content view instance from the configuration.
public var contentConfiguration: BackportUIContentConfiguration? {
get { nil } // we can't really support anything here, so for now we'll return nil
nonmutating set {
content.configuredView?.removeFromSuperview()
guard let configuration = newValue else { return }
let contentView = content.contentView
let configuredView = configuration.makeContentView()
configuredView.translatesAutoresizingMaskIntoConstraints = false
content.clipsToBounds = false
contentView.clipsToBounds = false
contentView.preservesSuperviewLayoutMargins = false
contentView.addSubview(configuredView)
let insets =
Mirror(reflecting: configuration)
.children.first(where: { $0.label == "insets" })?.value as? ProposedInsets
?? .unspecified
insets.top.flatMap { contentView.directionalLayoutMargins.top = $0 }
insets.bottom.flatMap { contentView.directionalLayoutMargins.bottom = $0 }
insets.leading.flatMap { contentView.directionalLayoutMargins.leading = $0 }
insets.trailing.flatMap { contentView.directionalLayoutMargins.trailing = $0 }
NSLayoutConstraint.activate([
configuredView.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
configuredView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
configuredView.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor),
configuredView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
])
var background: AnyView? {
Mirror(reflecting: configuration)
.children.first(where: { $0.label == "background" })?.value as? AnyView
}
background.flatMap {
let host = UIHostingController(rootView: $0, ignoreSafeArea: true)
content.backgroundView = host.view
}
background.flatMap {
let host = UIHostingController(rootView: $0, ignoreSafeArea: true)
content.selectedBackgroundView = host.view
}
content.configuredView = configuredView
}
}
}
#endif

View File

@ -0,0 +1,34 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
/// Provides optional inset values. `nil` is interpreted as: use system default
internal struct ProposedInsets: Equatable {
/// The proposed leading margin measured in points.
///
/// A value of `nil` tells the system to use a default value
public var leading: CGFloat?
/// The proposed trailing margin measured in points.
///
/// A value of `nil` tells the system to use a default value
public var trailing: CGFloat?
/// The proposed top margin measured in points.
///
/// A value of `nil` tells the system to use a default value
public var top: CGFloat?
/// The proposed bottom margin measured in points.
///
/// A value of `nil` tells the system to use a default value
public var bottom: CGFloat?
/// An insets proposal with all dimensions left unspecified.
public static var unspecified: ProposedInsets { .init() }
/// An insets proposal that contains zero for all dimensions.
public static var zero: ProposedInsets { .init(leading: 0, trailing: 0, top: 0, bottom: 0) }
}

View File

@ -0,0 +1,55 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
/// A proposal for the size
///
/// * The ``zero`` proposal; the size responds with its minimum size.
/// * The ``infinity`` proposal; the size responds with its maximum size.
/// * The ``unspecified`` proposal; the size responds with its system default size.
internal struct ProposedSize: Equatable, Sendable {
/// The proposed horizontal size measured in points.
///
/// A value of `nil` represents an unspecified width proposal.
public var width: CGFloat?
/// The proposed vertical size measured in points.
///
/// A value of `nil` represents an unspecified height proposal.
public var height: CGFloat?
/// A size proposal that contains zero in both dimensions.
public static var zero: ProposedSize { .init(width: 0, height: 0) }
/// The proposed size with both dimensions left unspecified.
///
/// Both dimensions contain `nil` in this size proposal.
public static var unspecified: ProposedSize { .init(width: nil, height: nil) }
/// A size proposal that contains infinity in both dimensions.
///
/// Both dimensions contain .infinity in this size proposal.
public static var infinity: ProposedSize { .init(width: .infinity, height: .infinity) }
/// Creates a new proposed size using the specified width and height.
///
/// - Parameters:
/// - width: A proposed width in points. Use a value of `nil` to indicate
/// that the width is unspecified for this proposal.
/// - height: A proposed height in points. Use a value of `nil` to
/// indicate that the height is unspecified for this proposal.
@inlinable public init(width: CGFloat?, height: CGFloat?) {
self.width = width
self.height = height
}
/// Creates a new proposed size from a specified size.
///
/// - Parameter size: A proposed size with dimensions measured in points.
@inlinable public init(_ size: CGSize) {
width = size.width
height = size.height
}
}

View File

@ -0,0 +1,22 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
#if os(iOS) || os(tvOS)
/// The requirements for an object that provides the configuration for a content view.
///
/// This protocol provides a blueprint for a content configuration object, which encompasses
/// default styling and content for a content view. The content configuration encapsulates
/// all of the supported properties and behaviors for content view customization.
/// You use the configuration to create the content view.
@available(iOS, deprecated: 14)
@available(tvOS, deprecated: 14)
@available(macOS, unavailable)
@available(watchOS, unavailable)
public protocol BackportUIContentConfiguration {
/// Initializes and returns a new instance of the content view using this configuration.
func makeContentView() -> UIView
}
#endif

View File

@ -0,0 +1,185 @@
// (c) 2022 and onwards Shaps Benkau (MIT License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
import SwiftUI
#if os(iOS) || os(tvOS)
@available(iOS, deprecated: 16)
@available(tvOS, deprecated: 16)
@available(macOS, unavailable)
@available(watchOS, unavailable)
extension Backport where Wrapped == Any {
/**
A content configuration suitable for hosting a hierarchy of SwiftUI views.
Use a value of this type, which conforms to the UIContentConfiguration protocol, with a UICollectionViewCell or UITableViewCell to host a hierarchy of SwiftUI views in a collection or table view, respectively. For example, the following shows a stack with an image and text inside the cell:
myCell.contentConfiguration = UIHostingConfiguration {
HStack {
Image(systemName: "star").foregroundStyle(.purple)
Text("Favorites")
Spacer()
}
}
You can also customize the background of the containing cell. The following example draws a blue background:
myCell.contentConfiguration = UIHostingConfiguration {
HStack {
Image(systemName: "star").foregroundStyle(.purple)
Text("Favorites")
Spacer()
}
}
.background {
Color.blue
}
*/
public struct UIHostingConfiguration<Label, Background>: BackportUIContentConfiguration
where Label: View, Background: View {
var content: Label
var background: AnyView?
var insets: ProposedInsets
var minSize: ProposedSize
/// Sets the background contents for the hosting configuration's enclosing
/// cell.
///
/// The following example sets a custom view to the background of the cell:
///
/// UIHostingConfiguration {
/// Text("My Contents")
/// }
/// .background {
/// MyBackgroundView()
/// }
///
/// - Parameter background: The contents of the SwiftUI hierarchy to be
/// shown inside the background of the cell.
public func background<B>(@ViewBuilder background: () -> B) -> Backport.UIHostingConfiguration<Label, B>
where B: View {
.init(content: content, background: AnyView(background()), insets: insets, minSize: minSize)
}
/// Sets the background contents for the hosting configuration's enclosing
/// cell.
///
/// The following example sets a custom view to the background of the cell:
///
/// UIHostingConfiguration {
/// Text("My Contents")
/// }
/// .background(Color.blue)
///
/// - Parameter style: The shape style to be used as the background of the
/// cell.
public func background<S>(_ style: S) -> Backport.UIHostingConfiguration<Label, S> where S: ShapeStyle {
.init(content: content, background: AnyView(style), insets: insets, minSize: minSize)
}
/// Initializes and returns a new instance of the content view using this configuration.
public func makeContentView() -> UIView {
let view = UIHostingController(
rootView: ZStack {
background
content
},
ignoreSafeArea: true
).view!
view.backgroundColor = .clear
view.clipsToBounds = false
return view
}
}
}
extension Backport.UIHostingConfiguration {
/// Sets the margins around the content of the configuration.
///
/// Use this modifier to replace the default margins applied to the root of
/// the configuration. The following example creates 20 points of space
/// between the content and the background on the horizontal edges.
///
/// UIHostingConfiguration {
/// Text("My Contents")
/// }
/// .margins(.horizontal, 20.0)
///
/// - Parameters:
/// - edges: The edges to apply the insets. Any edges not specified will
/// use the system default values. The default value is
/// ``Edge/Set/all``.
/// - length: The amount to apply.
public func margins(_ edges: Edge.Set = .all, _ length: CGFloat) -> Self {
var view = self
if edges.contains(.leading) { view.insets.leading = length }
if edges.contains(.trailing) { view.insets.trailing = length }
if edges.contains(.top) { view.insets.top = length }
if edges.contains(.bottom) { view.insets.bottom = length }
return view
}
/// Sets the margins around the content of the configuration.
///
/// Use this modifier to replace the default margins applied to the root of
/// the configuration. The following example creates 10 points of space
/// between the content and the background on the leading edge and 20 points
/// of space on the trailing edge:
///
/// UIHostingConfiguration {
/// Text("My Contents")
/// }
/// .margins(.horizontal, 20.0)
///
/// - Parameters:
/// - edges: The edges to apply the insets. Any edges not specified will
/// use the system default values. The default value is
/// ``Edge/Set/all``.
/// - insets: The insets to apply.
public func margins(_ edges: Edge.Set = .all, _ insets: EdgeInsets) -> Self {
var view = self
if edges.contains(.leading) { view.insets.leading = insets.leading }
if edges.contains(.trailing) { view.insets.trailing = insets.trailing }
if edges.contains(.top) { view.insets.top = insets.top }
if edges.contains(.bottom) { view.insets.bottom = insets.bottom }
return view
}
/// Sets the minimum size for the configuration.
///
/// Use this modifier to indicate that a configuration's associated cell can
/// be resized to a specific minimum. The following example allows the cell
/// to be compressed to zero size:
///
/// UIHostingConfiguration {
/// Text("My Contents")
/// }
/// .minSize(width: 0, height: 0)
///
/// - Parameter width: The value to use for the width dimension. A value of
/// `nil` indicates that the system default should be used.
/// - Parameter height: The value to use for the height dimension. A value
/// of `nil` indicates that the system default should be used.
// public func minSize(width: CGFloat? = nil, height: CGFloat? = nil) -> Self {
// var view = self
// view.minSize = .init(width: width, height: height)
// return view
// }
}
@available(iOS, deprecated: 16)
@available(tvOS, deprecated: 16)
@available(macOS, unavailable)
@available(watchOS, unavailable)
extension Backport.UIHostingConfiguration where Wrapped == Any, Background == EmptyView {
/// Creates a hosting configuration with the given contents.
///
/// - Parameter content: The contents of the SwiftUI hierarchy to be shown
/// inside the cell.
public init(@ViewBuilder content: () -> Label) {
self.init(content: content(), background: nil, insets: .init(), minSize: .unspecified)
}
}
#endif