Skip to content

Instantly share code, notes, and snippets.

@Saafo
Last active April 24, 2025 11:24
Show Gist options
  • Save Saafo/6ccb9a4d991c8adf4733e60ae9f4285c to your computer and use it in GitHub Desktop.
Save Saafo/6ccb9a4d991c8adf4733e60ae9f4285c to your computer and use it in GitHub Desktop.
SwiftUI Floating Debug Menu
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