Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions SwiftTweaks.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -108,6 +111,9 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
6347E4102C98D1880099192C /* TweakWindowGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TweakWindowGroup.swift; sourceTree = "<group>"; };
6347E4122C98D38B0099192C /* TweaksViewRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TweaksViewRepresentable.swift; sourceTree = "<group>"; };
6347E4142C98D59E0099192C /* View+Tweaks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Tweaks.swift"; sourceTree = "<group>"; };
930ECDB71DA6EEB9001009B3 /* TweakViewData+TweaksTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TweakViewData+TweaksTests.swift"; sourceTree = "<group>"; };
931472481BFFB0C800F66D20 /* UIColor+TweaksTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+TweaksTests.swift"; sourceTree = "<group>"; };
931A24711BFA77FB00E40192 /* TweakColorEditViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TweakColorEditViewController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 = "<group>";
};
93212CAE1CEE254900AA85D0 /* Shadow Template */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -293,6 +309,7 @@
93A84EFB1BEAE8A20022D2F3 /* Interface */,
93A84EFA1BEAE8940022D2F3 /* Model */,
93A84EFC1BEAE8AB0022D2F3 /* Utilities */,
6347E40F2C98D1740099192C /* SwiftUI */,
93A84ED61BEAE86E0022D2F3 /* Info.plist */,
931A24751BFA7EEE00E40192 /* Media.xcassets */,
);
Expand Down Expand Up @@ -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 */,
Expand All @@ -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 */,
Expand All @@ -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 */,
Expand Down
101 changes: 101 additions & 0 deletions SwiftTweaks/SwiftUI/TweakWindowGroup.swift
Original file line number Diff line number Diff line change
@@ -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<Content: View>: 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
63 changes: 63 additions & 0 deletions SwiftTweaks/SwiftUI/TweaksViewRepresentable.swift
Original file line number Diff line number Diff line change
@@ -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<Bool>

init(
tweakStore: TweakStore,
showingTweaks: Binding<Bool>
) {
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<Bool>) {
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
79 changes: 79 additions & 0 deletions SwiftTweaks/SwiftUI/View+Tweaks.swift
Original file line number Diff line number Diff line change
@@ -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`<Content: View>(_ 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