Implement "toasts" for iOS for UiFeedback.showMessage()

This commit is contained in:
Olof Hedman
2025-10-22 14:18:14 +02:00
parent 4112204a5f
commit 224fb9edd4

View File

@@ -2,6 +2,7 @@
package app.klottr.platform
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.useContents
import platform.Foundation.NSURL
@@ -11,14 +12,59 @@ import platform.Foundation.NSURLQueryItem
import platform.darwin.dispatch_async
import platform.darwin.dispatch_get_main_queue
import platform.Foundation.*
import platform.Foundation.NSTimeInterval
import platform.UIKit.*
import platform.darwin.dispatch_after
import platform.darwin.dispatch_time
private fun runOnMain(block: () -> Unit) {
if (NSThread.isMainThread) block() else {
dispatch_async(dispatch_get_main_queue(), block)
}
}
private fun activeWindow(): UIWindow? {
val app = UIApplication.sharedApplication
// iOS 13+ multi-scene: iterate foreground-active scenes
val scenes = app.connectedScenes // NSSet?
for (sceneAny in scenes) { // fast-enumeration over NSSet
val scene = sceneAny as? UIScene ?: continue
if (scene.activationState != UISceneActivationStateForegroundActive) continue
val windowScene = scene as? UIWindowScene ?: continue
val windows = windowScene.windows.mapNotNull { it as? UIWindow }
// Prefer key window
windows.firstOrNull { it.keyWindow }?.let { return it }
// Then any visible, normal-level window
windows.firstOrNull { !it.isHidden() && it.alpha > 0.0 && it.windowLevel == UIWindowLevelNormal }?.let { return it }
// Finally, any window in that scene
windows.firstOrNull()?.let { return it }
}
// Fallback (pre-iOS 13 or no active scene): scan application windows
val appWindows = app.windows.mapNotNull { it as? UIWindow }
appWindows.firstOrNull { it.keyWindow }?.let { return it }
return appWindows.firstOrNull()
}
private fun seconds(duration: UiFeedbackDuration): NSTimeInterval =
when (duration) {
UiFeedbackDuration.SHORT -> 2.0
UiFeedbackDuration.LONG -> 3.5
}
@OptIn(BetaInteropApi::class)
private fun urlEncode(query: String): String {
// Percent-encode for use in URL query
val encoded = (query as NSString)
// Create an NSString instance; avoid `as NSString`
val nsQuery: NSString = NSString.create(string = query)
return nsQuery
.stringByAddingPercentEncodingWithAllowedCharacters(
NSCharacterSet.URLQueryAllowedCharacterSet()
)
return encoded ?: query
) ?: query
}
@@ -80,13 +126,88 @@ actual object AppNavigator {
"maps://?daddr=$encoded&dirflg=d"
}
openUrlOnMain((NSURL(string = urlString)))
runOnMain {
UIApplication.sharedApplication.openURL(
NSURL(string = urlString),
options = emptyMap<Any?, Any?>(),
completionHandler = null
)
}
}
}
actual object UiFeedback {
actual fun showMessage(text: String, duration: UiFeedbackDuration) {
// No-op or implement a lightweight HUD; keeping no-op by default.
}
}
runOnMain {
val window = activeWindow() ?: return@runOnMain
// Container view (rounded, semi-opaque)
val container = UIView().apply {
backgroundColor = UIColor.blackColor.colorWithAlphaComponent(0.85)
layer.cornerRadius = 12.0
clipsToBounds = true
alpha = 0.0
translatesAutoresizingMaskIntoConstraints = false
accessibilityLabel = "Toast"
accessibilityTraits = UIAccessibilityTraitStaticText
}
// Label
val label = UILabel().apply {
textColor = UIColor.whiteColor
font = UIFont.systemFontOfSize(15.0)
numberOfLines = 0
textAlignment = NSTextAlignmentCenter
this.text = text
translatesAutoresizingMaskIntoConstraints = false
setContentCompressionResistancePriority(UILayoutPriorityRequired, UILayoutConstraintAxisHorizontal)
setContentHuggingPriority(UILayoutPriorityRequired, UILayoutConstraintAxisHorizontal)
}
container.addSubview(label)
window.addSubview(container)
// Layout: centerX, above bottom safe area; label has padding
val safe = window.safeAreaLayoutGuide
NSLayoutConstraint.activateConstraints(listOf(
container.centerXAnchor.constraintEqualToAnchor(window.centerXAnchor),
container.bottomAnchor.constraintEqualToAnchor(safe.bottomAnchor, constant = -64.0),
container.leadingAnchor.constraintGreaterThanOrEqualToAnchor(window.leadingAnchor, constant = 20.0),
container.trailingAnchor.constraintLessThanOrEqualToAnchor(window.trailingAnchor, constant = -20.0),
label.topAnchor.constraintEqualToAnchor(container.topAnchor, constant = 10.0),
label.bottomAnchor.constraintEqualToAnchor(container.bottomAnchor, constant = -10.0),
label.leadingAnchor.constraintEqualToAnchor(container.leadingAnchor, constant = 14.0),
label.trailingAnchor.constraintEqualToAnchor(container.trailingAnchor, constant = -14.0)
))
// Also cap max width to ~90% of window
container.widthAnchor.constraintLessThanOrEqualToAnchor(
window.widthAnchor, multiplier = 0.9
).active = true
// Fade in
UIView.animateWithDuration(
duration = 0.2,
animations = { container.alpha = 1.0 },
completion = null
)
// Schedule fade out + removal
val delay = seconds(duration)
dispatch_after(
// dispatch_time takes nanoseconds
dispatch_time(0u, (delay * 1_000_000_000L).toLong()),
dispatch_get_main_queue()
) {
UIView.animateWithDuration(
duration = 0.25,
animations = { container.alpha = 0.0 },
completion = { _ ->
container.removeFromSuperview()
}
)
}
}
}}