Files
mobile-ios/Rosetta/Core/Utils/DarkMode+Helpers.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)
}
}
}