swiftui-navigation/Sources/SwiftUINavigation/Binding.swift

171 lines
6.8 KiB
Swift

extension Binding {
/// Creates a binding by projecting the base value to an unwrapped value.
///
/// Useful for producing non-optional bindings from optional ones.
///
/// See ``IfLet`` for a view builder-friendly version of this initializer.
///
/// > Note: SwiftUI comes with an equivalent failable initializer, `Binding.init(_:)`, but using
/// > it can lead to crashes at runtime. [Feedback][FB8367784] has been filed, but in the meantime
/// > this initializer exists as a workaround.
///
/// [FB8367784]: https://gist.github.com/stephencelis/3a232a1b718bab0ae1127ebd5fcf6f97
///
/// - Parameter base: A value to project to an unwrapped value.
/// - Returns: A new binding or `nil` when `base` is `nil`.
public init?(unwrapping base: Binding<Value?>) {
self.init(unwrapping: base, case: /Optional.some)
}
/// Creates a binding by projecting the base enum value to an unwrapped case.
///
/// Useful for extracting bindings of non-optional state from the case of an enum.
///
/// See ``IfCaseLet`` for a view builder-friendly version of this initializer.
///
/// - Parameters:
/// - enum: An enum to project to a particular case.
/// - casePath: A case path that identifies a particular case to unwrap.
/// - Returns: A new binding or `nil` when `base` is `nil`.
public init?<Enum>(unwrapping enum: Binding<Enum>, case casePath: CasePath<Enum, Value>) {
guard var `case` = casePath.extract(from: `enum`.wrappedValue)
else { return nil }
self.init(
get: {
`case` = casePath.extract(from: `enum`.wrappedValue) ?? `case`
return `case`
},
set: {
`case` = $0
`enum`.transaction($1).wrappedValue = casePath.embed($0)
}
)
}
/// Creates a binding by projecting the current optional enum value to the value at a particular
/// case.
///
/// > Note: This method is constrained to optionals so that the projected value can write `nil`
/// > back to the parent, which is useful for navigation, particularly dismissal.
///
/// - Parameter casePath: A case path that identifies a particular case to unwrap.
/// - Returns: A binding to an enum case.
public func `case`<Enum, Case>(_ casePath: CasePath<Enum, Case>) -> Binding<Case?>
where Value == Enum? {
.init(
get: { self.wrappedValue.flatMap(casePath.extract(from:)) },
set: { newValue, transaction in
self.transaction(transaction).wrappedValue = newValue.map(casePath.embed)
}
)
}
/// Creates a binding by projecting the current optional value to a boolean describing if it's
/// non-`nil`.
///
/// Writing `false` to the binding will `nil` out the base value. Writing `true` does nothing.
///
/// - Returns: A binding to a boolean. Returns `true` if non-`nil`, otherwise `false`.
public func isPresent<Wrapped>() -> Binding<Bool>
where Value == Wrapped? {
.init(
get: { self.wrappedValue != nil },
set: { isPresent, transaction in
if !isPresent {
self.transaction(transaction).wrappedValue = nil
}
}
)
}
/// Creates a binding by projecting the current optional enum value to a boolean describing
/// whether or not it matches the given case path.
///
/// Writing `false` to the binding will `nil` out the base enum value. Writing `true` does
/// nothing.
///
/// Useful for interacting with APIs that take a binding of a boolean that you want to drive with
/// with an enum case that has no associated data.
///
/// For example, a view may model all of its presentations in a single route enum to prevent the
/// invalid states that can be introduced by holding onto many booleans and optionals, instead.
/// Even the simple case of two booleans driving two alerts introduces a potential runtime state
/// where both alerts are presented at the same time. By modeling these alerts using a two-case
/// enum instead of two booleans, we can eliminate this invalid state at compile time. Then we
/// can transform a binding to the route enum into a boolean binding using `isPresent`, so that it
/// can be passed to various presentation APIs.
///
/// ```swift
/// enum Route {
/// case deleteAlert
/// ...
/// }
///
/// struct ProductView: View {
/// @State var route: Route?
/// @State var product: Product
///
/// var body: some View {
/// Button("Delete") {
/// self.viewModel.route = .deleteAlert
/// }
/// // SwiftUI's vanilla alert modifier
/// .alert(
/// self.product.name
/// isPresented: self.$viewModel.route.isPresent(/Route.deleteAlert),
/// actions: {
/// Button("Delete", role: .destructive) {
/// self.viewModel.deleteConfirmationButtonTapped()
/// }
/// },
/// message: {
/// Text("Are you sure you want to delete this product?")
/// }
/// )
/// }
/// }
/// ```
///
/// - Parameter casePath: A case path that identifies a particular case to match.
/// - Returns: A binding to a boolean.
public func isPresent<Enum, Case>(_ casePath: CasePath<Enum, Case>) -> Binding<Bool>
where Value == Enum? {
self.case(casePath).isPresent()
}
/// Creates a binding that ignores writes to its wrapped value when equivalent to the new value.
///
/// Useful to minimize writes to bindings passed to SwiftUI APIs. For example, [`NavigationLink`
/// may write `nil` twice][FB9404926] when dismissing its destination via the navigation bar's
/// back button. Logic attached to this dismissal will execute twice, which may not be desirable.
///
/// [FB9404926]: https://gist.github.com/mbrandonw/70df235e42d505b3b1b9b7d0d006b049
///
/// - Parameter isDuplicate: A closure to evaluate whether two elements are equivalent, for
/// purposes of filtering writes. Return `true` from this closure to indicate that the second
/// element is a duplicate of the first.
public func removeDuplicates(by isDuplicate: @escaping (Value, Value) -> Bool) -> Self {
.init(
get: { self.wrappedValue },
set: { newValue, transaction in
guard !isDuplicate(self.wrappedValue, newValue) else { return }
self.transaction(transaction).wrappedValue = newValue
}
)
}
}
extension Binding where Value: Equatable {
/// Creates a binding that ignores writes to its wrapped value when equivalent to the new value.
///
/// Useful to minimize writes to bindings passed to SwiftUI APIs. For example, [`NavigationLink`
/// may write `nil` twice][FB9404926] when dismissing its destination via the navigation bar's
/// back button. Logic attached to this dismissal will execute twice, which may not be desirable.
///
/// [FB9404926]: https://gist.github.com/mbrandonw/70df235e42d505b3b1b9b7d0d006b049
public func removeDuplicates() -> Self {
self.removeDuplicates(by: ==)
}
}