Instantly share code, notes, and snippets.
Last active
April 24, 2025 11:24
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
Save Saafo/6ccb9a4d991c8adf4733e60ae9f4285c to your computer and use it in GitHub Desktop.
SwiftUI Floating Debug Menu
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
/// Float Debug Menu | |
/// | |
/// Usage: (It's simple! Just one line!) | |
/// | |
/// ```swift | |
/// FloatDebugMenu.registerTrigger("send1", action: { /* your custom logic */}) | |
/// FloatDebugMenu.registerTrigger("send2", action: { /* here */ }) | |
/// ``` | |
/// | |
/// Note: | |
/// - The FloatDebugView will appear on the keyWindow after first call to ``registerTrigger`` | |
/// - The order of items are sorted by title | |
/// - The older trigger with same title will be removed | |
public enum FloatDebugMenu { | |
public static func registerTrigger(_ title: String, action: @escaping () -> Void) { | |
let item = FloatDebugManager.TriggerItem(title: title, action: action) | |
FloatDebugManager.shared.items[title] = item | |
} | |
} | |
class FloatDebugManager: ObservableObject { | |
static let shared = FloatDebugManager() | |
@Published var items: [String: TriggerItem] = [:] { | |
didSet { | |
FloatDebugHostingController.shared.updateItems() | |
} | |
} | |
struct TriggerItem: Identifiable, Hashable, Comparable { | |
let title: String | |
let action: () -> Void | |
let id: UUID = UUID() | |
func hash(into hasher: inout Hasher) { | |
hasher.combine(id) | |
} | |
static func == (lhs: Self, rhs: Self) -> Bool { | |
lhs.id == rhs.id | |
} | |
static func < (lhs: FloatDebugManager.TriggerItem, rhs: FloatDebugManager.TriggerItem) -> Bool { | |
lhs.title < rhs.title | |
} | |
} | |
} | |
class FloatDebugHostingController: UIHostingController<FloatDebugView> { | |
static let shared = FloatDebugHostingController() | |
func updateItems() { | |
if !FloatDebugManager.shared.items.isEmpty { | |
show() | |
} | |
view.frame.size = view.intrinsicContentSize | |
} | |
private init() { | |
super.init(rootView: FloatDebugView()) | |
view.backgroundColor = nil | |
} | |
required init?(coder aDecoder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
override func viewWillLayoutSubviews() { | |
super.viewWillLayoutSubviews() | |
view.frame.size = view.intrinsicContentSize | |
} | |
func show() { | |
guard let window = UIApplication.shared.keyWindow else { return } | |
guard self.view.superview == nil else { return } | |
window.addSubview(self.view) | |
view.frame.origin.y = 200 | |
Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self, weak window] _ in | |
guard let self, let window else { return } | |
window.bringSubviewToFront(self.view) | |
} | |
} | |
} | |
struct FloatDebugView: View { | |
@ObservedObject private var manager = FloatDebugManager.shared | |
@State private var offset: CGSize = .zero | |
var body: some View { | |
VStack(spacing: 0) { | |
Image(systemName: "ant.fill") | |
.foregroundColor(.secondary) | |
.frame(height: 32) | |
// Temporarily not using List because of `scrollContentBackground` is only available iOS 16+ | |
ForEach(manager.items.values.sorted(by: <), id: \.self) { item in | |
Divider() | |
Button { | |
item.action() | |
} label: { | |
Text(item.title) | |
.minimumScaleFactor(0.8) | |
.lineLimit(1) | |
} | |
.frame(minWidth: 50, maxWidth: 120) | |
.frame(height: 30) | |
} | |
} | |
.padding(4) | |
.modifier(FloatBG()) | |
.cornerRadius(8) | |
.overlay( | |
RoundedRectangle(cornerRadius: 8) | |
.stroke(Color.secondary.opacity(0.6), lineWidth: 1 / UIScreen.main.scale) | |
) | |
.offset(offset) | |
.gesture( | |
DragGesture() | |
.onChanged { gesture in | |
self.offset = gesture.translation | |
} | |
.onEnded { gesture in | |
self.offset = .zero | |
let origin = FloatDebugHostingController.shared.view.frame.origin | |
let target = CGPoint(x: origin.x + gesture.translation.width, | |
y: origin.y + gesture.translation.height) | |
FloatDebugHostingController.shared.view.frame.origin = target | |
} | |
) | |
} | |
} | |
struct FloatBG: ViewModifier { | |
func body(content: Content) -> some View { | |
if #available(iOS 15, *) { | |
content | |
.background(.ultraThinMaterial) | |
} else { | |
content | |
.background(Color.gray.opacity(0.6)) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment