// // FlipView.swift // Tusker // // Created by Shadowfacts on 12/21/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import SwiftUI // Based on https://stackoverflow.com/a/60807269 struct FlipView : View { private let front: Front private let back: Back @State private var flipped = false @State private var angle: Angle = .zero @State private var width: CGFloat = 0 @State private var initialFlipped: Bool! init(@ViewBuilder front: () -> Front, @ViewBuilder back: () -> Back) { self.front = front() self.back = back() } var body: some View { ZStack { front .opacity(flipped ? 0.0 : 1.0) back .rotation3DEffect(.degrees(180), axis: (0, 1, 0)) .opacity(flipped ? 1.0 : 0.0) } .modifier(FlipEffect(flipped: $flipped, angle: angle.degrees, axis: (x: 0, y: 1))) .background(GeometryReader { proxy in Color.clear .preference(key: WidthPrefKey.self, value: proxy.size.width) .onPreferenceChange(WidthPrefKey.self) { newValue in width = newValue } }) .gesture( DragGesture() .onChanged({ value in if initialFlipped == nil { initialFlipped = flipped } let adj = (width / 2) - value.location.x let hyp = abs((width / 2) - value.startLocation.x) let clamped: Double = min(max(adj / hyp, -1), 1) let startedOnRight = value.startLocation.x > width / 2 angle = .radians(acos(clamped) + (startedOnRight != initialFlipped ? .pi : 0)) }) .onEnded({ value in initialFlipped = nil let deg = angle.degrees.truncatingRemainder(dividingBy: 360) if deg == 0 { angle = .zero } else if deg == 180 { angle = .degrees(180) } else { withAnimation(.easeInOut(duration: 0.25)) { angle = deg > 90 && deg < 270 ? .degrees(180) : .zero } } }) ) .onTapGesture { withAnimation(.easeInOut(duration: 0.5)) { angle = angle == .zero ? .degrees(180) : .zero } } } } // Based on https://swiftui-lab.com/swiftui-animations-part2/ struct FlipEffect: GeometryEffect { var animatableData: Double { get { angle } set { angle = newValue } } @Binding var flipped: Bool var angle: Double let axis: (x: CGFloat, y: CGFloat) func effectValue(size: CGSize) -> ProjectionTransform { // We schedule the change to be done after the view has finished drawing, // otherwise, we would receive a runtime error, indicating we are changing // the state while the view is being drawn. DispatchQueue.main.async { self.flipped = self.angle >= 90 && self.angle < 270 } let a = CGFloat(Angle.degrees(angle).radians) var transform3d = CATransform3DIdentity transform3d.m34 = -1/max(size.width, size.height) transform3d = CATransform3DRotate(transform3d, a, axis.x, axis.y, 0) transform3d = CATransform3DTranslate(transform3d, -size.width/2.0, -size.height/2.0, 0) let affineTransform = ProjectionTransform(CGAffineTransform(translationX: size.width/2.0, y: size.height / 2.0)) return ProjectionTransform(transform3d).concatenating(affineTransform) } } private struct WidthPrefKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } }