Skip to content
Draft
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
111 changes: 111 additions & 0 deletions Sources/SkipUI/SkipUI/System/SystemOverlays.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright 2023–2026 Skip
// SPDX-License-Identifier: MPL-2.0
#if SKIP
import android.os.Build
import android.view.ViewTreeObserver
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsAnimationControlListenerCompat
import androidx.core.view.WindowInsetsAnimationControllerCompat
import androidx.core.view.WindowInsetsControllerCompat

private let statusBarInsetTypes: Int = 1 // WindowInsetsCompat.Type.STATUS_BARS

/*
Our goal here is to use a `WindowInsetsAnimationControllerCompat` (an `animationController`) to hide/show the status bar and/or the home indicator bar.

We're not just using `WindowInsetsControllerCompat` hide/show, because that uses a really ugly animation by default. (The status bar first fades to transparent for a full second, and then disappears, without animation, causing a layout shift).

We get an animation controller via `WindowInsetsControllerCompat.controlWindowInsetsAnimation`, in the `onReady` callback.

The system can choose to "cancel" our animation controller at any time, notifying us in an `onCancelled` callback. For example, our animation controller will be cancelled if the window loses focus (e.g. if you open the overview/recents app-switcher screen). When we regain focus, we have to re-hide the status bar.

If you call `controlWindowInsetsAnimation` "too early", it might call `onCancelled` instantly, or, worse, it might just never call you back at all. (The documentation says that you're supposed to use addOnControllableInsetsChangedListener and wait for that to call you back, but I found that it wasn't working. Even when I got the callback saying that the status bars are controllable, when I called `controlWindowInsetsAnimation` immediately afterward, I never got a callback.)

So: we attempt to request a controller ASAP during UIApplication.launch, but that probably won't succeed. Eventually, if the code calls `setStatusBarHidden`, _that_ will request a controller again, which normally succeeds.

We also listen for window focus changes on the window; if we just regained focus, we request a new controller and then reassert our preferred status bar state.
*/

class SkipUISystemOverlaysCoordinator: WindowInsetsAnimationControlListenerCompat, ViewTreeObserver.OnWindowFocusChangeListener {
private var animationController: WindowInsetsAnimationControllerCompat?
private var pendingStatusBarHidden = false

private var activity: androidx.activity.ComponentActivity? { UIApplication.shared.androidActivity }
private var viewTreeObserver: ViewTreeObserver? { activity?.window.decorView.viewTreeObserver }

public func register() {
if let viewTreeObserver, viewTreeObserver.isAlive {
viewTreeObserver.addOnWindowFocusChangeListener(self)
}
requestAnimationController()
}

public func requestAnimationController() {
guard Build.VERSION.SDK_INT >= 30, let activity else { return }
activity.window.decorView.post {
guard let activity, animationController?.isReady != true,
let decor = activity.window.decorView, decor.hasWindowFocus()
else { return }
let wicc = WindowCompat.getInsetsController(activity.window, decor)
wicc.controlWindowInsetsAnimation(statusBarInsetTypes, -1, nil, nil, self)
}
}

public func unregister() {
if let viewTreeObserver {
viewTreeObserver.removeOnWindowFocusChangeListener(self)
}
animationController?.finish(true)
animationController = nil
pendingStatusBarHidden = false
}

public func setStatusBarHidden(_ hidden: Bool) {
pendingStatusBarHidden = hidden
// API 30+ uses `WindowInsetsControllerCompat.controlWindowInsetsAnimation` when available; below 30 uses hide/show.
if Build.VERSION.SDK_INT < 30 {
guard let activity else { return }
let wicc = WindowCompat.getInsetsController(activity.window, activity.window.decorView)
if hidden {
wicc.hide(statusBarInsetTypes)
} else {
wicc.show(statusBarInsetTypes)
}
} else {
if animationController?.isReady == true {
applyPendingInsets()
} else {
requestAnimationController()
}
}
}

private func applyPendingInsets() {
guard let c = animationController, c.isReady else { return }
let insets = pendingStatusBarHidden ? c.hiddenStateInsets : c.shownStateInsets
c.setInsetsAndAlpha(insets, c.currentAlpha, Float(1.0))
}

override func onWindowFocusChanged(hasFocus: Bool) {
guard hasFocus, let viewTreeObserver, viewTreeObserver.isAlive else { return }
if Build.VERSION.SDK_INT < 30 {
setStatusBarHidden(pendingStatusBarHidden)
} else {
requestAnimationController()
}
}

override func onReady(controller: WindowInsetsAnimationControllerCompat, types: Int) {
animationController = controller
applyPendingInsets()
}

override func onFinished(controller: WindowInsetsAnimationControllerCompat) {}

override func onCancelled(controller: WindowInsetsAnimationControllerCompat?) {
animationController = nil
requestAnimationController()
}
}

#endif
9 changes: 9 additions & 0 deletions Sources/SkipUI/SkipUI/UIKit/UIApplication.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ let logger: Logger = Logger(subsystem: "skip.ui", category: "SkipUI") // adb log
#if SKIP
private var requestPermissionLauncher: ActivityResultLauncher<String>?
private let waitingContinuations: MutableList<Continuation<Bool>> = mutableListOf<Continuation<Bool>>()
private var systemOverlaysCoordinator: SkipUISystemOverlaysCoordinator?
#endif

private init() {
Expand Down Expand Up @@ -91,14 +92,22 @@ let logger: Logger = Logger(subsystem: "skip.ui", category: "SkipUI") // adb log
} catch {
android.util.Log.w("SkipUI", "error initializing permission launcher", error as? Throwable)
}
shared.systemOverlaysCoordinator = SkipUISystemOverlaysCoordinator()
shared.systemOverlaysCoordinator?.register()
}
}

func onActivityDestroy() {
systemOverlaysCoordinator?.unregister()
systemOverlaysCoordinator = nil
// The permission launcher appears to hold a strong reference to the activity, so we must nil it to avoid memory leaks
self.requestPermissionLauncher = nil
}

public func setStatusBarHidden(_ hidden: Bool) {
systemOverlaysCoordinator?.setStatusBarHidden(hidden)
}

/// Requests the given permission.
/// - Parameters:
/// - permission: the name of the permission, such as `android.permission.POST_NOTIFICATIONS`
Expand Down
18 changes: 16 additions & 2 deletions Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1276,9 +1276,23 @@ extension View {
#endif
}

@available(*, unavailable)
public func statusBarHidden(_ hidden: Bool = true) -> some View {
// SKIP @bridge
public func statusBarHidden(_ hidden: Bool = true) -> any View {
#if SKIP
return ModifiedContent(content: self, modifier: SideEffectModifier { _ in
DisposableEffect(hidden) {
UIApplication.shared.setStatusBarHidden(hidden)
onDispose {
if hidden {
UIApplication.shared.setStatusBarHidden(false)
}
}
}
return ComposeResult.ok
})
#else
return self
#endif
}

@available(*, unavailable)
Expand Down