Skip to content

Instantly share code, notes, and snippets.

@marcpalmer
Created January 14, 2024 19:48
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save marcpalmer/e6e84bff04556e19b1200476f86e8c2c to your computer and use it in GitHub Desktop.
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
//
// 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