Last active
September 18, 2025 06:23
-
-
Save luthviar/b2fa9be4413e0b278b7bde72f06cffdd to your computer and use it in GitHub Desktop.
SimpleToastSwiftUI+UIKitReliable.swift
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
| // | |
| // ContentView22.swift | |
| // Coba14July2025 | |
| // | |
| // Created by Luthfi Abdurrahim on 18/09/25. | |
| // | |
| import SwiftUI | |
| import Combine | |
| import UIKit | |
| // MARK: - Public API | |
| enum SimpleToast { | |
| /// Shows a toast for ~3 seconds above everything, even during navigation/presentations. | |
| static func show(_ message: String) { | |
| SimpleToastManager.shared.enqueue(message: message) | |
| } | |
| } | |
| // MARK: - Manager | |
| private final class SimpleToastManager { | |
| static let shared = SimpleToastManager() | |
| private var queue: [String] = [] | |
| private var isShowing = false | |
| private var cancellables = Set<AnyCancellable>() | |
| private let lock = NSLock() | |
| private init() { | |
| NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification) | |
| .sink { [weak self] _ in self?.drainIfPossible() } | |
| .store(in: &cancellables) | |
| } | |
| func enqueue(message: String) { | |
| lock.lock() | |
| queue.append(message) | |
| let canStart = !isShowing | |
| lock.unlock() | |
| if canStart { drainIfPossible() } | |
| } | |
| private func drainIfPossible() { | |
| DispatchQueue.main.async { [weak self] in | |
| guard let self else { return } | |
| guard UIApplication.shared.applicationState == .active else { return } | |
| lock.lock() | |
| guard !isShowing, let next = queue.first else { lock.unlock(); return } | |
| isShowing = true | |
| lock.unlock() | |
| OverlayWindow.shared.present(message: next, onHide: { [weak self] in | |
| guard let self else { return } | |
| self.lock.lock() | |
| if !self.queue.isEmpty { _ = self.queue.removeFirst() } | |
| self.isShowing = false | |
| let hasMore = !self.queue.isEmpty | |
| self.lock.unlock() | |
| if hasMore { self.drainIfPossible() } | |
| }) | |
| } | |
| } | |
| } | |
| // MARK: - Overlay Window | |
| private final class OverlayWindow: UIWindow { | |
| static let shared = OverlayWindow() | |
| private var hosting: UIHostingController<SimpleToastContainer>? | |
| private init() { | |
| // Pick an active window scene if available | |
| let scene = UIApplication.shared.connectedScenes | |
| .compactMap { $0 as? UIWindowScene } | |
| .first { $0.activationState == .foregroundActive } | |
| ?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first | |
| if let scene { | |
| super.init(windowScene: scene) | |
| } else { | |
| super.init(frame: UIScreen.main.bounds) | |
| } | |
| windowLevel = .alert + 1 // above everything, including status bar & sheets | |
| isHidden = true | |
| backgroundColor = .clear | |
| } | |
| required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } | |
| func present(message: String, onHide: @escaping () -> Void) { | |
| let root = SimpleToastContainer(message: message) { [weak self] in | |
| self?.hideIfNeeded(completion: onHide) | |
| } | |
| let hc = UIHostingController(rootView: root) | |
| hc.view.backgroundColor = .clear | |
| rootViewController = hc | |
| hosting = hc | |
| isHidden = false | |
| hc.view.alpha = 0 | |
| hc.view.transform = CGAffineTransform(translationX: 0, y: -12) | |
| // Animate in | |
| UIView.animate(withDuration: 0.28, delay: 0, options: [.curveEaseOut]) { | |
| hc.view.alpha = 1 | |
| hc.view.transform = .identity | |
| } | |
| // Auto hide after ~3 seconds | |
| let hideAfter = DispatchTime.now() + .seconds(3) | |
| DispatchQueue.main.asyncAfter(deadline: hideAfter) { [weak self] in | |
| self?.hideIfNeeded(completion: onHide) | |
| } | |
| } | |
| private var isHiding = false | |
| private func hideIfNeeded(completion: @escaping () -> Void) { | |
| guard !isHiding, !isHidden else { return } | |
| isHiding = true | |
| guard let v = hosting?.view else { | |
| cleanup() | |
| completion() | |
| return | |
| } | |
| UIView.animate(withDuration: 0.22, delay: 0, options: [.curveEaseIn]) { | |
| v.alpha = 0 | |
| v.transform = CGAffineTransform(translationX: 0, y: -10) | |
| } completion: { [weak self] _ in | |
| self?.cleanup() | |
| completion() | |
| } | |
| } | |
| private func cleanup() { | |
| isHiding = false | |
| isHidden = true | |
| rootViewController = nil | |
| hosting = nil | |
| } | |
| // Allow taps to pass through to underlying app except when actually | |
| // touching the visible toast content. This keeps the screen interactive | |
| // while the toast is showing. | |
| override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { | |
| // If window is hidden, ignore completely | |
| guard !isHidden else { return nil } | |
| // Ask normal system first | |
| let hit = super.hitTest(point, with: event) | |
| // If the hit view is exactly the root hosting controller's root view | |
| // (which spans the full screen) we treat it as background and | |
| // let the event pass through (return nil). Any deeper SwiftUI | |
| // subview (the toast bubble) should still receive touches. | |
| if hit === rootViewController?.view { return nil } | |
| return hit | |
| } | |
| } | |
| // MARK: - SwiftUI View | |
| private struct SimpleToastContainer: View { | |
| let message: String | |
| let onTapToDismiss: () -> Void | |
| var body: some View { | |
| ZStack { | |
| // The toast pinned to safe top center | |
| VStack { | |
| Spacer().frame(height: 12) | |
| HStack { | |
| Text(message) | |
| .font(.system(size: 15, weight: .semibold)) | |
| .foregroundColor(.white) | |
| .padding(.horizontal, 25) | |
| .padding(.vertical, 16) | |
| } | |
| .background( | |
| RoundedRectangle(cornerRadius: .infinity, style: .continuous) | |
| .fill(Color.black.opacity(0.7)) | |
| ) | |
| .overlay( | |
| RoundedRectangle(cornerRadius: .infinity) | |
| .stroke(Color.white.opacity(0.12), lineWidth: 0.5) | |
| ) | |
| .shadow(radius: 4, y: 2) | |
| //.contentShape(Rectangle()) | |
| .contentShape(RoundedRectangle(cornerRadius: .infinity)) | |
| .onTapGesture { onTapToDismiss() } | |
| Spacer() // keep at top area | |
| } | |
| .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) | |
| .padding(.horizontal, 20) | |
| .padding(.top, topSafeInset + 8) | |
| .ignoresSafeArea() | |
| } | |
| .accessibilityElement(children: .ignore) | |
| .accessibilityLabel(Text(message)) | |
| .accessibilityAddTraits(.isStaticText) | |
| } | |
| private var topSafeInset: CGFloat { | |
| UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0 | |
| } | |
| } | |
| // MARK: - Example (remove in production) | |
| // Example SwiftUI usage: | |
| struct ContentView22: View { | |
| @State private var shouldNavigate = false | |
| var body: some View { | |
| NavigationView { | |
| VStack(spacing: 24) { | |
| Button("Show Toast") { | |
| SimpleToast.show("Link telah disalin!") | |
| } | |
| Button("should navigate") { | |
| shouldNavigate = true | |
| } | |
| } | |
| .padding() | |
| .background { | |
| NavigationLink(isActive: $shouldNavigate) { | |
| DetailView() | |
| } label: { | |
| EmptyView() | |
| } | |
| } | |
| } | |
| } | |
| } | |
| struct DetailView: View { | |
| var body: some View { | |
| NavigationView { | |
| VStack(spacing: 24) { | |
| Text("Detail Screen") | |
| Button("Show Toast here too") { | |
| SimpleToast.show("Success! 🎉") | |
| } | |
| } | |
| } | |
| } | |
| } |
Author
luthviar
commented
Sep 18, 2025
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment