256 lines
10 KiB
Swift
256 lines
10 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Dark Mode Animation System
|
|
// Ported from DarkModeAnimation reference project (Balaji Venkatesh).
|
|
// Circular reveal mask animation for theme switching.
|
|
|
|
// Step 1: Wrap app's main content with DarkModeWrapper in RosettaApp.
|
|
// Step 2: Place DarkModeButton wherever the toggle should appear.
|
|
|
|
/// Creates an overlay UIWindow for the dark mode transition animation.
|
|
/// Supports three theme modes: dark, light, system (via `rosetta_theme_mode`).
|
|
struct DarkModeWrapper<Content: View>: View {
|
|
@ViewBuilder var content: Content
|
|
@State private var overlayWindow: UIWindow?
|
|
@AppStorage("rosetta_theme_mode") private var themeModeRaw: String = "dark"
|
|
|
|
var body: some View {
|
|
content
|
|
.onAppear {
|
|
// Migrate legacy `rosetta_dark_mode` Bool → `rosetta_theme_mode` String
|
|
let defaults = UserDefaults.standard
|
|
if defaults.object(forKey: "rosetta_theme_mode") == nil,
|
|
let legacy = defaults.object(forKey: "rosetta_dark_mode") as? Bool {
|
|
themeModeRaw = legacy ? "dark" : "light"
|
|
defaults.removeObject(forKey: "rosetta_dark_mode")
|
|
}
|
|
if overlayWindow == nil {
|
|
if let windowScene = activeWindowScene {
|
|
let overlayWindow = UIWindow(windowScene: windowScene)
|
|
overlayWindow.tag = 0320
|
|
overlayWindow.backgroundColor = .clear
|
|
overlayWindow.isHidden = false
|
|
overlayWindow.isUserInteractionEnabled = false
|
|
self.overlayWindow = overlayWindow
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: themeModeRaw, initial: true) { _, newValue in
|
|
if let windowScene = activeWindowScene {
|
|
let style: UIUserInterfaceStyle
|
|
switch newValue {
|
|
case "light": style = .light
|
|
case "system": style = .unspecified
|
|
default: style = .dark
|
|
}
|
|
let bgColor: UIColor = (style == .light) ? .white : .black
|
|
for window in windowScene.windows {
|
|
window.overrideUserInterfaceStyle = style
|
|
// Match window background to app background — prevents
|
|
// systemBackground (dark gray) from showing as a line
|
|
// in the bottom safe area.
|
|
if window.tag != 0320 {
|
|
window.backgroundColor = bgColor
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var activeWindowScene: UIWindowScene? {
|
|
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
|
return scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first
|
|
}
|
|
}
|
|
|
|
/// Theme toggle button with sun/moon icon and circular reveal animation.
|
|
/// Cycles: dark → light → dark (quick toggle, full control in Appearance settings).
|
|
struct DarkModeButton: View {
|
|
@State private var buttonRect: CGRect = .zero
|
|
/// Local icon state — changes INSTANTLY on tap (no round-trip through UserDefaults).
|
|
@State private var showMoonIcon: Bool = true
|
|
@AppStorage("rosetta_theme_mode") private var themeModeRaw: String = "dark"
|
|
|
|
var body: some View {
|
|
Button(action: {
|
|
showMoonIcon.toggle()
|
|
animateScheme()
|
|
}, label: {
|
|
Image(systemName: showMoonIcon ? "moon.fill" : "sun.max.fill")
|
|
.font(.system(size: 16, weight: .medium))
|
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
|
.symbolEffect(.bounce, value: showMoonIcon)
|
|
.frame(width: 44, height: 44)
|
|
})
|
|
.buttonStyle(.plain)
|
|
.onAppear { showMoonIcon = themeModeRaw == "dark" || themeModeRaw == "system" }
|
|
.darkModeButtonRect { rect in
|
|
buttonRect = rect
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func animateScheme() {
|
|
guard let windowScene = activeWindowScene,
|
|
let window = windowScene.windows.first(where: { $0.isKeyWindow }),
|
|
let overlayWindow = windowScene.windows.first(where: { $0.tag == 0320 })
|
|
else { return }
|
|
|
|
let targetDark = showMoonIcon
|
|
let frameSize = window.frame.size
|
|
|
|
// 1. Capture old state SYNCHRONOUSLY — afterScreenUpdates:false is fast (~5ms).
|
|
let previousImage = window.darkModeSnapshotFast(frameSize)
|
|
|
|
// 2. Show freeze frame IMMEDIATELY — user sees instant response.
|
|
// Overlay stays non-interactive so button taps always pass through.
|
|
let imageView = UIImageView(image: previousImage)
|
|
imageView.frame = window.frame
|
|
imageView.contentMode = .scaleAspectFit
|
|
overlayWindow.addSubview(imageView)
|
|
|
|
// 3. Switch theme underneath the freeze frame (invisible to user).
|
|
themeModeRaw = targetDark ? "dark" : "light"
|
|
|
|
// 4. Capture new state asynchronously after layout settles.
|
|
Task {
|
|
try? await Task.sleep(for: .seconds(0.06))
|
|
window.layoutIfNeeded()
|
|
let currentImage = window.darkModeSnapshot(frameSize)
|
|
|
|
let swiftUIView = DarkModeOverlayView(
|
|
buttonRect: buttonRect,
|
|
previousImage: previousImage,
|
|
currentImage: currentImage
|
|
)
|
|
|
|
let hostingController = UIHostingController(rootView: swiftUIView)
|
|
hostingController.view.backgroundColor = .clear
|
|
hostingController.view.frame = window.frame
|
|
hostingController.view.tag = 1009
|
|
overlayWindow.addSubview(hostingController.view)
|
|
imageView.removeFromSuperview()
|
|
}
|
|
}
|
|
|
|
private var activeWindowScene: UIWindowScene? {
|
|
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
|
return scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first
|
|
}
|
|
}
|
|
|
|
// MARK: - Button Rect Tracking
|
|
|
|
private struct DarkModeRectKey: PreferenceKey {
|
|
static var defaultValue: CGRect = .zero
|
|
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
|
|
value = nextValue()
|
|
}
|
|
}
|
|
|
|
private extension View {
|
|
@ViewBuilder
|
|
func darkModeButtonRect(value: @escaping (CGRect) -> Void) -> some View {
|
|
self
|
|
.overlay {
|
|
GeometryReader { geometry in
|
|
let rect = geometry.frame(in: .global)
|
|
Color.clear
|
|
.preference(key: DarkModeRectKey.self, value: rect)
|
|
.onPreferenceChange(DarkModeRectKey.self) { rect in
|
|
value(rect)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Circular Mask Animation Overlay
|
|
|
|
private struct DarkModeOverlayView: View {
|
|
var buttonRect: CGRect
|
|
@State var previousImage: UIImage?
|
|
@State var currentImage: UIImage?
|
|
@State private var maskAnimation: Bool = false
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let size = geometry.size
|
|
let maskRadius = size.height / 10
|
|
|
|
if let previousImage, let currentImage {
|
|
ZStack {
|
|
Image(uiImage: previousImage)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: size.width, height: size.height)
|
|
|
|
Image(uiImage: currentImage)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: size.width, height: size.height)
|
|
.mask(alignment: .topLeading) {
|
|
Circle()
|
|
.frame(
|
|
width: buttonRect.width * (maskAnimation ? maskRadius : 1),
|
|
height: buttonRect.height * (maskAnimation ? maskRadius : 1),
|
|
alignment: .bottomLeading
|
|
)
|
|
.frame(width: buttonRect.width, height: buttonRect.height)
|
|
.offset(x: buttonRect.minX, y: buttonRect.minY)
|
|
.ignoresSafeArea()
|
|
}
|
|
}
|
|
.task {
|
|
guard !maskAnimation else { return }
|
|
|
|
withAnimation(.easeInOut(duration: 0.9), completionCriteria: .logicallyComplete) {
|
|
maskAnimation = true
|
|
} completion: {
|
|
self.previousImage = nil
|
|
self.currentImage = nil
|
|
maskAnimation = false
|
|
if let window = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows.first(where: { $0.tag == 0320 }) {
|
|
for view in window.subviews {
|
|
view.removeFromSuperview()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Reverse masking — cut out the button area so original button stays visible
|
|
.mask {
|
|
Rectangle()
|
|
.overlay(alignment: .topLeading) {
|
|
Circle()
|
|
.frame(width: buttonRect.width, height: buttonRect.height)
|
|
.offset(x: buttonRect.minX, y: buttonRect.minY)
|
|
.blendMode(.destinationOut)
|
|
}
|
|
}
|
|
.ignoresSafeArea()
|
|
}
|
|
}
|
|
|
|
// MARK: - UIView Snapshot
|
|
|
|
private extension UIView {
|
|
/// Full-fidelity snapshot — waits for pending layout. Use for the NEW state.
|
|
func darkModeSnapshot(_ size: CGSize) -> UIImage {
|
|
let renderer = UIGraphicsImageRenderer(size: size)
|
|
return renderer.image { _ in
|
|
drawHierarchy(in: .init(origin: .zero, size: size), afterScreenUpdates: true)
|
|
}
|
|
}
|
|
|
|
/// Fast snapshot of CURRENT appearance — no layout wait (~5ms vs ~80ms).
|
|
/// Use for the OLD state (already on screen, nothing pending).
|
|
func darkModeSnapshotFast(_ size: CGSize) -> UIImage {
|
|
let renderer = UIGraphicsImageRenderer(size: size)
|
|
return renderer.image { _ in
|
|
drawHierarchy(in: .init(origin: .zero, size: size), afterScreenUpdates: false)
|
|
}
|
|
}
|
|
}
|