Created
January 14, 2024 19:48
-
-
Save marcpalmer/e6e84bff04556e19b1200476f86e8c2c to your computer and use it in GitHub Desktop.
Modifiers for animating views from one place to another in the view hierarchy, even if the clipping changes
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// Floating.swift | |
// Captionista | |
// | |
// Created by Marc Palmer on 24/02/2023. | |
// | |
import SwiftUI | |
/// Set to true for debug prints. | |
private var debug = false | |
/// This is a view that wraps the content that should float around between zones. | |
struct FloatingView<Content, ID>: View where Content: View, ID: Hashable { | |
let zone: ID? | |
@Binding var frameStore: FrameDataStore | |
let content: Content | |
init(zone: ID?, frameStore: Binding<FrameDataStore>, content: () -> Content) { | |
self.zone = zone | |
self._frameStore = frameStore | |
self.content = content() | |
} | |
init(zone: ID?, frameStore: Binding<FrameDataStore>, content: Content) { | |
self.zone = zone | |
self._frameStore = frameStore | |
self.content = content | |
} | |
var body: some View { | |
ZStack { | |
if let zoneToUse = zone, let frame = frameStore[AnyHashable(zoneToUse)]?.frame { | |
let _ = { | |
if debug { | |
print("🛸 Floating view showing in zone \(zoneToUse) positioned at \(String(describing: frame))") | |
} | |
}() | |
content | |
.frame(width: frame.width, height: frame.height) | |
.position(x: frame.midX, y: frame.midY) | |
} | |
} | |
.frame(maxWidth: .infinity, maxHeight: .infinity) | |
} | |
} | |
struct FloatingZoneView<Content>: View where Content: View { | |
let zone: AnyHashable | |
@Binding var frameStore: FrameDataStore | |
let content: Content | |
init(zone: AnyHashable, frameStore: Binding<FrameDataStore>, content: () -> Content) { | |
self.zone = zone | |
self._frameStore = frameStore | |
self.content = content() | |
} | |
init(zone: AnyHashable, frameStore: Binding<FrameDataStore>, content: Content) { | |
self.zone = zone | |
self._frameStore = frameStore | |
self.content = content | |
} | |
var body: some View { | |
content | |
#if DEBUG | |
.overlay { | |
Group { | |
if debug { | |
Color.red.opacity(0.4) | |
.overlay(alignment: .bottomTrailing) { | |
Text(verbatim: "Zone: \(String(describing: zone))") | |
} | |
} else { | |
Color.clear | |
} | |
} | |
.allowsHitTesting(false) | |
} | |
#endif | |
.capturingFrame(id: zone) | |
#if DEBUG | |
.onChange(of: frameStore) { [previousStore = frameStore] newStore in | |
if debug && (previousStore[zone] != newStore[zone]) { | |
print("🛸 Zone frame changed: \(zone): \(String(describing: newStore[zone]))") | |
} | |
} | |
.onAppear { | |
if debug { | |
print("🛸 Zone defined: \(zone): \(String(describing: frameStore[zone]))") | |
} | |
} | |
#endif | |
} | |
} | |
struct FloatingZoneModifier: ViewModifier { | |
let zone: AnyHashable | |
@EnvironmentObject private var zones: FloatingZonesContext | |
func body(content: Content) -> some View { | |
FloatingZoneView(zone: zone, frameStore: $zones.frames, content: content) | |
} | |
} | |
struct FloatingViewModifier<ID>: ViewModifier where ID: Hashable { | |
let zone: ID? | |
@EnvironmentObject private var zones: FloatingZonesContext | |
func body(content: Content) -> some View { | |
FloatingView(zone: zone, frameStore: $zones.frames, content: content) | |
} | |
} | |
/// This defines the "coordinate space" of the floating context so that the floating views | |
/// capture frames relative to this. | |
private struct FloatingContextViewModifier: ViewModifier { | |
@StateObject var zones = FloatingZonesContext() | |
func body(content: Content) -> some View { | |
content | |
#if DEBUG | |
.overlay { | |
Group { | |
if debug { | |
Color.green.opacity(0.4) | |
.overlay(alignment: .bottomTrailing) { | |
Text(verbatim: "Context: \(zones.id)") | |
} | |
} else { | |
Color.clear | |
} | |
} | |
.allowsHitTesting(false) | |
} | |
#endif | |
.storeFrames(in: $zones.frames, animation: nil) | |
.environmentObject(zones) | |
} | |
} | |
extension View { | |
func definesFloatingZone(_ name: String) -> some View { | |
return modifier(FloatingZoneModifier(zone: AnyHashable(name))) | |
} | |
/// For use with e.g. `Namespace.ID`/`@Namespace` as the zone ID | |
func definesFloatingZone(_ zone: AnyHashable) -> some View { | |
return modifier(FloatingZoneModifier(zone: zone)) | |
} | |
func floating<ID>(inZone zone: ID?) -> some View where ID: Hashable { | |
modifier(FloatingViewModifier(zone: zone)) | |
} | |
func floatingContext() -> some View { | |
modifier(FloatingContextViewModifier()) | |
} | |
} | |
private class FloatingZonesContext: ObservableObject { | |
private static var nextID: UInt = 0 | |
let id: UInt | |
@Published fileprivate(set) var frames: FrameDataStore = [:] | |
init() { | |
Self.nextID += 1 | |
id = Self.nextID | |
} | |
} | |
private struct ContentView: View { | |
@State var currentZone: Namespace.ID? | |
@Namespace var topZone | |
@Namespace var bottomZone | |
var stretchableCroppingVideoPlayer: some View { | |
Color.yellow | |
.overlay { | |
Ellipse() | |
.fill(Color.red) | |
.aspectRatio(9/16, contentMode: .fit) | |
.frame(height: 600) | |
} | |
.aspectRatio(currentZone == topZone ? 16/9 : 9/16, contentMode: .fit) | |
.clipped() | |
} | |
var body: some View { | |
VStack(spacing: 100) { | |
ZStack { | |
VStack { | |
Text(verbatim: "This top content is a clipped version of the ellipse") | |
Color.blue | |
.frame(width: 100, height: 50) | |
.definesFloatingZone(topZone) // Declare a zone the floater can occupy | |
} | |
} | |
VStack { | |
Text(verbatim: "This bottom content is a unclipped version of the ellipse") | |
Color.green | |
.frame(width: 300, height: 200) | |
.definesFloatingZone(bottomZone) // Declare a zone the floater can occupy | |
} | |
Button(action: { | |
withAnimation { | |
currentZone = currentZone == topZone ? bottomZone : topZone | |
} | |
}) { | |
Text(verbatim: "Toggle active zone") | |
.foregroundColor(.white) | |
} | |
.buttonStyle(.borderedProminent) | |
} | |
.frame(maxWidth: .infinity, maxHeight: .infinity) | |
.overlay { | |
stretchableCroppingVideoPlayer | |
.floating(inZone: currentZone) // Declare this as the floater and the zone it should be in | |
} | |
.onAppear { | |
if currentZone == nil { | |
currentZone = topZone | |
} | |
} | |
.floatingContext() // Declare a view that can contain floaters. This is required to create shared state. | |
.edgesIgnoringSafeArea(.all) | |
} | |
} | |
#if APP_DEBUG | |
struct FloatingZone_Previews: PreviewProvider { | |
struct SheetHarness: View { | |
@State var show = false | |
var body: some View { | |
VStack { | |
Button(action: { show = true }) { | |
Text(verbatim: "Show") | |
} | |
} | |
.sheet(isPresented: $show) { | |
NavigationView { | |
ContentView() | |
.navigationBarTitle("Test") | |
.navigationBarTitleDisplayMode(.inline) | |
.toolbar { | |
ToolbarItem(placement: .cancellationAction) { | |
Button(action: { show = false }) { | |
Text(verbatim: "Cancel") | |
} | |
} | |
} | |
} | |
.navigationViewStyle(.stack) | |
} | |
} | |
} | |
static var previews: some View { | |
ContentView() | |
.previewDisplayName("Inline") | |
SheetHarness() | |
.previewDisplayName("Sheet") | |
.previewDevice(PreviewDevice.iPadPro_11_inch) | |
} | |
} | |
#endif |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment