diff --git a/README.md b/README.md index 6191795..2bfba62 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,10 @@ For more examples, check out the example project’s `ViewController.swift` file By default, SwiftTweaks uses a shake gesture to bring up the UI, but you can also use a custom gesture! +### Step Three (SwiftUI): Set TweakWindowGroup as your root scene in your App + +`TweakWindowGroup` is a drop-in replacement for `WindowGroup` in your `App` struct. By default, it uses a shake gesture to bring up the UI. Custom gestures/two finger double-tap is not yet supported for SwiftUI. + ## Installation #### Swift Package Manager diff --git a/SwiftTweaks.xcodeproj/project.pbxproj b/SwiftTweaks.xcodeproj/project.pbxproj index 150c145..66ab6a9 100644 --- a/SwiftTweaks.xcodeproj/project.pbxproj +++ b/SwiftTweaks.xcodeproj/project.pbxproj @@ -8,6 +8,9 @@ /* Begin PBXBuildFile section */ 07190479224D931A00D28728 /* HapticsPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939CB70E218CFE570041E3EA /* HapticsPlayer.swift */; }; + 6347E4112C98D1880099192C /* TweakWindowGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6347E4102C98D1880099192C /* TweakWindowGroup.swift */; }; + 6347E4132C98D38B0099192C /* TweaksViewRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6347E4122C98D38B0099192C /* TweaksViewRepresentable.swift */; }; + 6347E4152C98D59E0099192C /* View+Tweaks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6347E4142C98D59E0099192C /* View+Tweaks.swift */; }; 930ECDB81DA6EEB9001009B3 /* TweakViewData+TweaksTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 930ECDB71DA6EEB9001009B3 /* TweakViewData+TweaksTests.swift */; }; 931472491BFFB0C800F66D20 /* UIColor+TweaksTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 931472481BFFB0C800F66D20 /* UIColor+TweaksTests.swift */; }; 9314724C1BFFB41700F66D20 /* TweakWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93A3AF311BF1677B00CAD43B /* TweakWindow.swift */; }; @@ -108,6 +111,9 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 6347E4102C98D1880099192C /* TweakWindowGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TweakWindowGroup.swift; sourceTree = ""; }; + 6347E4122C98D38B0099192C /* TweaksViewRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TweaksViewRepresentable.swift; sourceTree = ""; }; + 6347E4142C98D59E0099192C /* View+Tweaks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Tweaks.swift"; sourceTree = ""; }; 930ECDB71DA6EEB9001009B3 /* TweakViewData+TweaksTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TweakViewData+TweaksTests.swift"; sourceTree = ""; }; 931472481BFFB0C800F66D20 /* UIColor+TweaksTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+TweaksTests.swift"; sourceTree = ""; }; 931A24711BFA77FB00E40192 /* TweakColorEditViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TweakColorEditViewController.swift; sourceTree = ""; }; @@ -187,6 +193,16 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 6347E40F2C98D1740099192C /* SwiftUI */ = { + isa = PBXGroup; + children = ( + 6347E4102C98D1880099192C /* TweakWindowGroup.swift */, + 6347E4122C98D38B0099192C /* TweaksViewRepresentable.swift */, + 6347E4142C98D59E0099192C /* View+Tweaks.swift */, + ); + path = SwiftUI; + sourceTree = ""; + }; 93212CAE1CEE254900AA85D0 /* Shadow Template */ = { isa = PBXGroup; children = ( @@ -293,6 +309,7 @@ 93A84EFB1BEAE8A20022D2F3 /* Interface */, 93A84EFA1BEAE8940022D2F3 /* Model */, 93A84EFC1BEAE8AB0022D2F3 /* Utilities */, + 6347E40F2C98D1740099192C /* SwiftUI */, 93A84ED61BEAE86E0022D2F3 /* Info.plist */, 931A24751BFA7EEE00E40192 /* Media.xcassets */, ); @@ -476,6 +493,7 @@ buildActionMask = 2147483647; files = ( 93AA344F1BEBBD2D004B734B /* TweakStore.swift in Sources */, + 6347E4152C98D59E0099192C /* View+Tweaks.swift in Sources */, 931A24721BFA77FB00E40192 /* TweakColorEditViewController.swift in Sources */, 939F2CD91CB810D300345E03 /* SpringAnimationTweakTemplate.swift in Sources */, 9338E9E71CB57068002A92BE /* UIImage+SwiftTweaks.swift in Sources */, @@ -497,6 +515,7 @@ 93AA34571BEBC654004B734B /* TweaksViewController.swift in Sources */, 93A84EF01BEAE88D0022D2F3 /* HashingUtilities.swift in Sources */, 937AA3711CB61BDE000928C5 /* FloatingTweakGroupViewController.swift in Sources */, + 6347E4132C98D38B0099192C /* TweaksViewRepresentable.swift in Sources */, 93C942591BFBDC550054811A /* TweakBinding.swift in Sources */, 9345EC0E1BF2B9100086AB5D /* TweakCollection.swift in Sources */, D5CE0BDC1DC7DFC200F79235 /* TweakBindingIdentifier.swift in Sources */, @@ -505,6 +524,7 @@ AA356AF01EB3B5A90063F4E2 /* TweakAction.swift in Sources */, 93212CB01CEE255F00AA85D0 /* ShadowTweakTemplate.swift in Sources */, 93B058E31CC44D8900AB2759 /* Precision.swift in Sources */, + 6347E4112C98D1880099192C /* TweakWindowGroup.swift in Sources */, 9338787E1BF6A4C5007DF1B4 /* TweakTableCell.swift in Sources */, 933223541CB83F0C002D586B /* BasicAnimationTweakTemplate.swift in Sources */, 93E777041BED4BBD003F0DE2 /* TweakLibrary.swift in Sources */, diff --git a/SwiftTweaks/SwiftUI/TweakWindowGroup.swift b/SwiftTweaks/SwiftUI/TweakWindowGroup.swift new file mode 100644 index 0000000..32284c9 --- /dev/null +++ b/SwiftTweaks/SwiftUI/TweakWindowGroup.swift @@ -0,0 +1,101 @@ +// +// TweakWindowGroup.swift +// SwiftTweaks +// +// Created by Daniel Amitay on 9/16/24. +// Copyright © 2024 Khan Academy. All rights reserved. +// + +// Guarded by SwiftUI to prevent compilation errors when SwiftUI is not available. +#if canImport(SwiftUI) + +import SwiftUI + +@available(iOS 15.0, *) +/// A `Scene` that presents a `TweakStore` UI when a certain gesture is recognized. +/// Use this in place of the WindowGroup in your App's @main struct. +public struct TweakWindowGroup: Scene { + public enum GestureType { + /// Shake the device, like you're trying to undo some text + case shake + } + + /// The GestureType used to determine when to present the UI. + let gestureType: GestureType + /// The TweakStore to use for the UI. + let tweakStore: TweakStore + /// Your app's content. + let content: () -> Content + + /// Whether or not the Tweak UI is currently being shown. + @State private var showingTweaks: Bool = false + /// Whether or not the device is currently being shaken. + @State private var shaking: Bool = false + + /// The amount of time you need to shake your device to bring up the Tweaks UI + private let shakeWindowTimeInterval: TimeInterval = 0.4 + + public init( + gestureType: GestureType = .shake, + tweakStore: TweakStore, + @ViewBuilder content: @escaping () -> Content + ) { + self.gestureType = gestureType + self.tweakStore = tweakStore + self.content = content + } + + public var body: some Scene { + WindowGroup { + VStack { + content() + } + .sheet(isPresented: $showingTweaks) { + TweaksViewRepresentable( + tweakStore: tweakStore, + showingTweaks: $showingTweaks + ) + } + .if(gestureType == .shake && tweakStore.enabled) { view in + view.onShake { phase in + switch phase { + case .began: + shaking = true + DispatchQueue.main.asyncAfter(deadline: .now() + shakeWindowTimeInterval) { + if self.shouldShakePresentTweaks { + self.showingTweaks = true + } + } + case .ended: + shaking = false + } + } + } + } + } +} + +@available(iOS 15.0, *) +fileprivate extension TweakWindowGroup { + /// We need to know if we're running in the simulator (because shake gestures don't have a time duration in the simulator) + var runningInSimulator: Bool { +#if targetEnvironment(simulator) + return true +#else + return false +#endif + } + + /// We only want to present the Tweaks UI if we're shaking the device and the Tweaks UI is enabled + var shouldShakePresentTweaks: Bool { + if tweakStore.enabled { + switch gestureType { + case .shake: return shaking || runningInSimulator + } + } else { + return false + } + } +} + +#endif diff --git a/SwiftTweaks/SwiftUI/TweaksViewRepresentable.swift b/SwiftTweaks/SwiftUI/TweaksViewRepresentable.swift new file mode 100644 index 0000000..5b4b155 --- /dev/null +++ b/SwiftTweaks/SwiftUI/TweaksViewRepresentable.swift @@ -0,0 +1,63 @@ +// +// TweaksViewRepresentable.swift +// SwiftTweaks +// +// Created by Daniel Amitay on 9/16/24. +// Copyright © 2024 Khan Academy. All rights reserved. +// + +// Guarded by SwiftUI to prevent compilation errors when SwiftUI is not available. +#if canImport(SwiftUI) + +import SwiftUI + +@available(iOS 13.0, *) +/// A `UIViewControllerRepresentable` that presents the `TweaksViewController`. +public struct TweaksViewRepresentable: UIViewControllerRepresentable { + let tweakStore: TweakStore + let showingTweaks: Binding + + init( + tweakStore: TweakStore, + showingTweaks: Binding + ) { + self.tweakStore = tweakStore + self.showingTweaks = showingTweaks + } + + public func makeUIViewController(context: Context) -> TweaksViewController { + let delegate = RepresentableDelegate(showingTweaks: showingTweaks) + return TweaksViewController( + tweakStore: tweakStore, + delegate: delegate + ) + } + + public func updateUIViewController(_ uiViewController: TweaksViewController, context: Context) { + // no-op + } +} + +@available(iOS 13.0, *) +fileprivate class RepresentableDelegate: TweaksViewControllerDelegate { + @Binding var showingTweaks: Bool + + init(showingTweaks: Binding) { + self._showingTweaks = showingTweaks + } + + func tweaksViewControllerRequestsDismiss(_ tweaksViewController: TweaksViewController, completion: (() -> ())?) { + showingTweaks = false + completion?() + } +} + +@available(iOS 13.0, *) +#Preview { + TweaksViewRepresentable( + tweakStore: .init(tweaks: [], enabled: true), + showingTweaks: .constant(true) + ) +} + +#endif diff --git a/SwiftTweaks/SwiftUI/View+Tweaks.swift b/SwiftTweaks/SwiftUI/View+Tweaks.swift new file mode 100644 index 0000000..f38b443 --- /dev/null +++ b/SwiftTweaks/SwiftUI/View+Tweaks.swift @@ -0,0 +1,79 @@ +// +// View+Tweaks.swift +// SwiftTweaks +// +// Created by Daniel Amitay on 9/16/24. +// Copyright © 2024 Khan Academy. All rights reserved. +// + +// Guarded by SwiftUI to prevent compilation errors when SwiftUI is not available. +#if canImport(SwiftUI) + +import SwiftUI + +/// Whether the device began or ended shaking +internal enum ShakePhase { + case began + case ended +} + +@available(iOS 15.0, *) +/// `View` extension to add a shake gesture recognizer. +internal extension View { + func onShake(_ block: @escaping (_ phase: ShakePhase) -> Void) -> some View { + self.overlay { + ShakeViewRepresentable(onShake: block) + .allowsHitTesting(false) + .opacity(0.0) + } + } +} + +@available(iOS 13.0, *) +/// `View` extension to conditionally apply a transformation. +internal extension View { + @ViewBuilder func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { + if condition { + transform(self) + } else { + self + } + } +} + +@available(iOS 13.0, *) +/// Hook into the responder chain to detect shake gestures +internal struct ShakeViewRepresentable: UIViewControllerRepresentable { + let onShake: (ShakePhase) -> () + + class ShakeViewController: UIViewController { + let onShake: ((ShakePhase) -> ()) + init(onShake: @escaping (ShakePhase) -> Void) { + self.onShake = onShake + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + override func motionBegan(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { + if motion == .motionShake { + onShake(.began) + } + super.motionBegan(motion, with: event) + } + override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { + if motion == .motionShake { + onShake(.ended) + } + super.motionEnded(motion, with: event) + } + } + func makeUIViewController(context: Context) -> ShakeViewController { + return ShakeViewController(onShake: onShake) + } + func updateUIViewController(_ uiViewController: ShakeViewController, context: Context) { + // no-op + } +} + +#endif