WIP: WebView, PhaseAnimator, Visual Effects and cleanup

This commit is contained in:
Shaps Benkau 2023-06-16 14:09:53 +01:00
parent 8e92217f52
commit 395b999d0a
6 changed files with 433 additions and 5 deletions

View File

@ -0,0 +1,86 @@
import SwiftUI
#if os(iOS)
internal struct VisualEffectBlur<Content: View>: View {
/// Defaults to .systemMaterial
var blurStyle: UIBlurEffect.Style
/// Defaults to nil
var vibrancyStyle: UIVibrancyEffectStyle?
var content: Content
public init(blurStyle: UIBlurEffect.Style = .systemMaterial, vibrancyStyle: UIVibrancyEffectStyle? = nil, @ViewBuilder content: () -> Content) {
self.blurStyle = blurStyle
self.vibrancyStyle = vibrancyStyle
self.content = content()
}
public var body: some View {
Representable(blurStyle: blurStyle, vibrancyStyle: vibrancyStyle, content: content)
.accessibility(hidden: Content.self == EmptyView.self)
}
}
private extension VisualEffectBlur {
struct Representable: UIViewRepresentable {
var blurStyle: UIBlurEffect.Style
var vibrancyStyle: UIVibrancyEffectStyle?
var content: Content
func makeUIView(context: Context) -> UIVisualEffectView {
context.coordinator.blurView
}
func updateUIView(_ view: UIVisualEffectView, context: Context) {
context.coordinator.update(content: content, blurStyle: blurStyle, vibrancyStyle: vibrancyStyle)
}
func makeCoordinator() -> Coordinator {
Coordinator(content: content)
}
}
}
private extension VisualEffectBlur.Representable {
class Coordinator {
let blurView = UIVisualEffectView()
let vibrancyView = UIVisualEffectView()
let hostingController: UIHostingController<Content>
init(content: Content) {
hostingController = UIHostingController(rootView: content)
hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostingController.view.backgroundColor = nil
blurView.contentView.addSubview(vibrancyView)
blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
vibrancyView.contentView.addSubview(hostingController.view)
vibrancyView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
}
func update(content: Content, blurStyle: UIBlurEffect.Style, vibrancyStyle: UIVibrancyEffectStyle?) {
hostingController.rootView = content
let blurEffect = UIBlurEffect(style: blurStyle)
blurView.effect = blurEffect
if let vibrancyStyle = vibrancyStyle {
vibrancyView.effect = UIVibrancyEffect(blurEffect: blurEffect, style: vibrancyStyle)
} else {
vibrancyView.effect = nil
}
hostingController.view.setNeedsDisplay()
}
}
}
extension VisualEffectBlur where Content == EmptyView {
init(blurStyle: UIBlurEffect.Style = .systemMaterial) {
self.init(blurStyle: blurStyle, vibrancyStyle: nil) {
EmptyView()
}
}
}
#endif

View File

@ -0,0 +1,71 @@
import SwiftUI
#if os(macOS)
internal struct VisualEffectBlur: View {
private var material: NSVisualEffectView.Material
private var blendingMode: NSVisualEffectView.BlendingMode
private var state: NSVisualEffectView.State
public init(
material: NSVisualEffectView.Material = .headerView,
blendingMode: NSVisualEffectView.BlendingMode = .withinWindow,
state: NSVisualEffectView.State = .followsWindowActiveState
) {
self.material = material
self.blendingMode = blendingMode
self.state = state
}
public var body: some View {
Representable(
material: material,
blendingMode: blendingMode,
state: state
).accessibility(hidden: true)
}
}
// MARK: - Representable
private extension VisualEffectBlur {
struct Representable: NSViewRepresentable {
var material: NSVisualEffectView.Material
var blendingMode: NSVisualEffectView.BlendingMode
var state: NSVisualEffectView.State
func makeNSView(context: Context) -> NSVisualEffectView {
context.coordinator.visualEffectView
}
func updateNSView(_ view: NSVisualEffectView, context: Context) {
context.coordinator.update(material: material)
context.coordinator.update(blendingMode: blendingMode)
context.coordinator.update(state: state)
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
}
class Coordinator {
let visualEffectView = NSVisualEffectView()
init() {
visualEffectView.blendingMode = .withinWindow
}
func update(material: NSVisualEffectView.Material) {
visualEffectView.material = material
}
func update(blendingMode: NSVisualEffectView.BlendingMode) {
visualEffectView.blendingMode = blendingMode
}
func update(state: NSVisualEffectView.State) {
visualEffectView.state = state
}
}
}
#endif

View File

@ -24,10 +24,6 @@ public extension Backport where Wrapped: View {
///
/// - Returns: A view that fires an action when the specified value changes.
@ViewBuilder
// func onChange<Value: Equatable>(of value: Value, perform action: @escaping (Value) -> Void) -> some View {
// wrapped.modifier(ChangeModifier(value: value, action: action))
// }
// @ViewBuilder
func onChange<Value: Equatable>(of value: Value, perform action: @escaping (Value) -> Void) -> some View {
if #available(iOS 14, tvOS 14, macOS 11, watchOS 7, *) {
wrapped.onChange(of: value, perform: action)

View File

@ -33,7 +33,7 @@ public extension Backport<Any>.OpenURLAction.Result {
return .handled
}
#if os(iOS)
#if os(iOS) && canImport(SafariServices)
static func safari(_ url: URL, configure: (inout SafariConfiguration) -> Void) -> Self {
let scene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene
let window = scene?.windows.first { $0.isKeyWindow }

View File

@ -0,0 +1,255 @@
import SwiftUI
import SwiftBackports
@available(iOS, deprecated: 17)
@available(tvOS, deprecated: 17)
@available(watchOS, deprecated: 10)
@available(macOS, deprecated: 14)
extension Backport<Any> {
/// A container that animates its content by automatically cycling through
/// a collection of phases that you provide, each defining a discrete step
/// within an animation.
public struct PhaseAnimator<Phase, Content>: View where Phase: Equatable, Content: View {
/// Cycles through the given phases when the trigger value changes,
/// updating the view builder closure that you supply.
///
/// The phases that you provide specify the individual values that will
/// be animated to when the trigger value changes.
///
/// When the view first appears, the value from the first phase is provided
/// to the `content` closure. When the trigger value changes, the content
/// closure is called with the value from the second phase and its
/// corresponding animation. This continues until the last phase is
/// reached, after which the first phase is animated to.
///
/// - Parameters:
/// - phases: Phases defining the states that will be cycled through.
/// This sequence must not be empty. If an empty sequence is provided,
/// a visual warning will be displayed in place of this view, and a
/// warning will be logged.
/// - trigger: A value to observe for changes.
/// - content: A view builder closure.
/// - animation: A closure that returns the animation to use when
/// transitioning to the next phase. If `nil` is returned, the
/// transition will not be animated.
public init(_ phases: some Sequence<Phase>, trigger: some Equatable, @ViewBuilder content: @escaping (Phase) -> Content, animation: @escaping (Phase) -> Animation? = { _ in .default }) {
let phases = phases.map { $0 }
self.phases = phases
self.trigger = Trigger(trigger)
self.content = content
self.animation = animation
precondition(!phases.isEmpty, "PhaseAnimator requires at least one phase value")
_phase = .init(initialValue: phases.first!)
}
/// Cycles through the given phases continuously, updating the content
/// using the view builder closure that you supply.
///
/// The phases that you provide define the individual values that will
/// be animated between.
///
/// When the view first appears, the the first phase is provided
/// to the `content` closure. The animator then immediately animates
/// to the second phase, using an animation returned from the `animation`
/// closure. This continues until the last phase is reached, after which
/// the animator loops back to the beginning.
///
/// - Parameters:
/// - phases: Phases defining the states that will be cycled through.
/// This sequence must not be empty. If an empty sequence is provided,
/// a visual warning will be displayed in place of this view, and a
/// warning will be logged.
/// - content: A view builder closure.
/// - animation: A closure that returns the animation to use when
/// transitioning to the next phase. If `nil` is returned, the
/// transition will not be animated.
public init(_ phases: some Sequence<Phase>, @ViewBuilder content: @escaping (Phase) -> Content, animation: @escaping (Phase) -> Animation? = { _ in .default }) {
let phases = phases.map { $0 }
self.phases = phases
self.trigger = Trigger()
self.content = content
self.animation = animation
precondition(!phases.isEmpty, "PhaseAnimator requires at least one phase value")
_phase = .init(initialValue: phases.first!)
}
@State private var phase: Phase
@State private var animate: Bool = false
private let phases: [Phase]
private let trigger: Trigger
private let content: (Phase) -> Content
private let animation: (Phase) -> Animation?
public var body: some View {
content(phase)
.animation(animation(phase)?.repeatCount(trigger.repeatForever ? .max : 0), value: phase)
.backport.onChange(of: animate) { _ in
print("before: \(self.phase), after: \(next ?? phase)")
phase = next ?? phase
}
.backport.onChange(of: trigger) { _ in
animate.toggle()
}
.onAppear {
if !trigger.repeatForever {
animate.toggle()
}
}
}
private var next: Phase? {
guard let index = phases.firstIndex(of: phase) else { return nil }
let next = phases.index(after: index)
guard phases.indices.contains(next) else {
return trigger.repeatForever ? phases.first! : nil
}
return phases[next]
}
}
}
extension Backport where Wrapped: View {
/// Cycles through the given phases when the trigger value changes,
/// updating the view using the modifiers you apply in `body`.
///
/// The phases that you provide specify the individual values that will
/// be animated to when the trigger value changes.
///
/// When the view first appears, the value from the first phase is provided
/// to the `content` closure. When the trigger value changes, the content
/// closure is called with the value from the second phase and its
/// corresponding animation. This continues until the last phase is
/// reached, after which the first phase is animated to.
///
/// - Parameters:
/// - phases: Phases defining the states that will be cycled through.
/// This sequence must not be empty. If an empty sequence is provided,
/// a visual warning will be displayed in place of this view, and a
/// warning will be logged.
/// - trigger: A value to observe for changes.
/// - content: A view builder closure that takes two parameters. The first
/// parameter is a proxy value representing the modified view. The
/// second parameter is the current phase.
/// - animation: A closure that returns the animation to use when
/// transitioning to the next phase. If `nil` is returned, the
/// transition will not be animated.
public func phaseAnimator<Phase>(_ phases: some Sequence<Phase>, trigger: some Equatable, @ViewBuilder content: @escaping (Backport<Any>.PlaceholderContentView<Self>, Phase) -> some View, animation: @escaping (Phase) -> Animation? = { _ in .default }) -> some View where Phase: Equatable {
Backport<Any>.PhaseAnimator(phases, trigger: trigger) { phase in
Backport<Any>.PlaceholderContentView(wrapped)
} animation: { phase in
animation(phase)
}
}
/// Cycles through the given phases continuously, updating the content
/// using the view builder closure that you supply.
///
/// The phases that you provide define the individual values that will
/// be animated between.
///
/// When the view first appears, the the first phase is provided
/// to the `content` closure. The animator then immediately animates
/// to the second phase, using an animation returned from the `animation`
/// closure. This continues until the last phase is reached, after which
/// the animator loops back to the beginning.
///
/// - Parameters:
/// - phases: Phases defining the states that will be cycled through.
/// This sequence must not be empty. If an empty sequence is provided,
/// a visual warning will be displayed in place of this view, and a
/// warning will be logged.
/// - content: A view builder closure that takes two parameters. The first
/// parameter is a proxy value representing the modified view. The
/// second parameter is the current phase.
/// - animation: A closure that returns the animation to use when
/// transitioning to the next phase. If `nil` is returned, the
/// transition will not be animated.
public func phaseAnimator<Phase>(_ phases: some Sequence<Phase>, @ViewBuilder content: @escaping (Backport<Any>.PlaceholderContentView<Self>, Phase) -> some View, animation: @escaping (Phase) -> Animation? = { _ in .default }) -> some View where Phase: Equatable {
Backport<Any>.PhaseAnimator(phases, trigger: Trigger()) { phase in
Backport<Any>.PlaceholderContentView(wrapped)
} animation: { phase in
animation(phase)
}
}
}
extension Backport<Any> {
/// A placeholder used to construct an inline modifier, transition, or other
/// helper type.
///
/// You don't use this type directly. Instead SwiftUI creates this type on
/// your behalf.
public struct PlaceholderContentView<Value>: View {
let content: any View
init(_ content: Value) where Value: View {
self.content = content
}
}
}
public extension Backport.PlaceholderContentView {
var body: some View {
AnyView(content)
}
}
struct Trigger {
let base: Any
let repeatForever: Bool
private let comparator: (Any) -> Bool
init() {
self.base = false
self.comparator = { _ in true }
self.repeatForever = true
}
init<E>(_ base: E) where E: Equatable {
self.base = base
self.comparator = { $0 as! E == base }
self.repeatForever = false
}
}
extension Trigger: Equatable {
static func == (lhs: Trigger, rhs: Trigger) -> Bool {
return lhs.comparator(rhs.base) && rhs.comparator(lhs.base)
}
}
//private struct InfiniteIterator<Base: Collection>: IteratorProtocol {
//
// private let collection: Base
// private var index: Base.Index
//
// /// Creates a new iterator for the specified base collection
// ///
// /// - Parameter collection: The base collection to iterate over
// init(collection: Base, startingAt index: Base.Index? = nil) {
// self.collection = collection
//
// if let index = index, collection.indices.contains(index) {
// self.index = index
// } else {
// self.index = collection.startIndex
// }
// }
//
// /// Returns the next element. If the endIndex has been reached, this will returns the first element again.
// ///
// /// - Returns: Returns the next element. If the collection is empty, this returns nil
// mutating func next() -> Base.Iterator.Element? {
// guard !collection.isEmpty else { return nil }
//
// let result = collection[index]
// collection.formIndex(after: &index)
//
// if index == collection.endIndex {
// index = collection.startIndex
// }
//
// return result
// }
//
//}

View File

@ -12,6 +12,9 @@ public extension Backport<Any> {
case destructiveAction
case principal
case bottomBar
// #if os(iOS)
// case keyboard
// #endif
var isLeading: Bool {
switch self {
@ -67,12 +70,14 @@ struct ToolbarModifier: ViewModifier {
let trailingItems: [Backport<Any>.ToolbarItem]
let principalItems: [Backport<Any>.ToolbarItem]
let bottomBarItems: [Backport<Any>.ToolbarItem]
// let keyboardItems: [Backport<Any>.ToolbarItem]
init(items: [Backport<Any>.ToolbarItem]) {
leadingItems = items.filter { $0.placement.isLeading }
trailingItems = items.filter { $0.placement.isTrailing }
principalItems = items.filter { $0.placement == .principal }
bottomBarItems = items.filter { $0.placement == .bottomBar }
// keyboardItems = items.filter { $0.placement == .keyboard }
}
@ViewBuilder
@ -119,6 +124,17 @@ struct ToolbarModifier: ViewModifier {
}
}
// @ViewBuilder
// private var keyboard: some View {
// if !keyboardItems.isEmpty {
// HStack {
// ForEach(keyboardItems, id: \.id) { item in
// item.content
// }
// }
// }
// }
func body(content: Content) -> some View {
content
.navigationBarItems(leading: leading, trailing: trailing)
@ -140,6 +156,10 @@ struct ToolbarModifier: ViewModifier {
return .init(customView: view)
}
}
// if !keyboardItems.isEmpty {
// let toolbar = UIToolbar()
// }
}
}
}