Skip to content

Commit df54fe5

Browse files
authored
Merge pull request #309 from kdroidFilter/fix-tray-app-lifecycle
Preserve component states and update TrayApp management
2 parents e523ee3 + ce7001d commit df54fe5

File tree

1 file changed

+103
-80
lines changed
  • src/commonMain/kotlin/com/kdroid/composetray/tray/api

1 file changed

+103
-80
lines changed

src/commonMain/kotlin/com/kdroid/composetray/tray/api/TrayApp.kt

Lines changed: 103 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -259,10 +259,11 @@ fun ApplicationScope.TrayApp(
259259
*
260260
* Key features:
261261
* - Creates a system tray icon with customizable appearance
262-
* - Manages a popup window that preserves state when hidden
262+
* - Manages a popup window that preserves ALL states (including remember values)
263263
* - Handles platform-specific behaviors (Windows, macOS, Linux)
264264
* - Provides smooth fade animations
265265
* - Manages focus and outside-click behaviors
266+
* - Uses a hybrid approach: window stays mounted but is moved off-screen when hidden
266267
*/
267268
@ExperimentalTrayAppApi
268269
@Composable
@@ -283,6 +284,9 @@ fun ApplicationScope.TrayApp(
283284
// Internal state for managing window display with animation
284285
var shouldShowWindow by remember { mutableStateOf(false) }
285286

287+
// Track if window has been created at least once
288+
var windowCreated by remember { mutableStateOf(false) }
289+
286290
// System information
287291
val isDark = isMenuBarInDarkMode()
288292
val os = getOperatingSystem()
@@ -389,6 +393,7 @@ fun ApplicationScope.TrayApp(
389393
if (isVisible) {
390394
// Show window immediately
391395
shouldShowWindow = true
396+
windowCreated = true
392397
lastShownAt = System.currentTimeMillis()
393398
} else {
394399
// Hide window after fade animation completes
@@ -436,6 +441,7 @@ fun ApplicationScope.TrayApp(
436441
}
437442

438443
shouldShowWindow = true
444+
windowCreated = true
439445
lastShownAt = System.currentTimeMillis()
440446
}
441447
}
@@ -459,95 +465,112 @@ fun ApplicationScope.TrayApp(
459465
focusable = false,
460466
) { }
461467

462-
// Main popup window - ALWAYS in composition to preserve state
463-
// The window is always composed but visibility is controlled
464-
val widthPx = windowSize.width.value.toInt()
465-
val heightPx = windowSize.height.value.toInt()
466-
val windowPosition = getTrayWindowPositionForInstance(tray.instanceKey(), widthPx, heightPx)
468+
// Main popup window - always mounted once created to preserve states
469+
// Uses hybrid approach: moves off-screen instead of unmounting
470+
if (windowCreated) {
471+
// Calculate position: off-screen when hidden, correct position when visible
472+
val widthPx = windowSize.width.value.toInt()
473+
val heightPx = windowSize.height.value.toInt()
474+
val windowPosition = if (shouldShowWindow) {
475+
// Visible: use correct position near tray icon
476+
getTrayWindowPositionForInstance(tray.instanceKey(), widthPx, heightPx)
477+
} else {
478+
// Hidden: move far off-screen to avoid taskbar appearance
479+
WindowPosition(-10000.dp, -10000.dp)
480+
}
467481

468-
DialogWindow(
469-
onCloseRequest = { requestHide() },
470-
title = "",
471-
undecorated = true,
472-
resizable = false,
473-
focusable = true,
474-
alwaysOnTop = true,
475-
transparent = true,
476-
visible = shouldShowWindow, // Control visibility without unmounting
477-
state = rememberDialogState(position = windowPosition, size = windowSize)
478-
) {
479-
// One-time setup for window behaviors and listeners
480-
DisposableEffect(Unit) {
481-
// Set window name for monitoring
482-
try { window.name = WindowVisibilityMonitor.TRAY_DIALOG_NAME } catch (_: Throwable) {}
483-
484-
// Focus listener for auto-hide on focus loss
485-
val focusListener = object : WindowFocusListener {
486-
override fun windowGainedFocus(e: WindowEvent?) = Unit
487-
override fun windowLostFocus(e: WindowEvent?) {
488-
lastFocusLostAt = System.currentTimeMillis()
489-
// Windows: Ignore focus loss during startup period
490-
if (os == WINDOWS && lastFocusLostAt < autoHideEnabledAt) {
491-
return
482+
DialogWindow(
483+
onCloseRequest = { requestHide() },
484+
title = "",
485+
undecorated = true,
486+
resizable = false,
487+
focusable = shouldShowWindow, // Only focusable when visible
488+
alwaysOnTop = shouldShowWindow, // Only on top when visible
489+
transparent = true,
490+
visible = true, // Always visible to the system
491+
state = rememberDialogState(position = windowPosition, size = windowSize)
492+
) {
493+
DisposableEffect(Unit) {
494+
// Set window name for monitoring
495+
try { window.name = WindowVisibilityMonitor.TRAY_DIALOG_NAME } catch (_: Throwable) {}
496+
497+
// Focus listener for auto-hide on focus loss
498+
val focusListener = object : WindowFocusListener {
499+
override fun windowGainedFocus(e: WindowEvent?) = Unit
500+
override fun windowLostFocus(e: WindowEvent?) {
501+
lastFocusLostAt = System.currentTimeMillis()
502+
// Windows: Ignore focus loss during startup period
503+
if (os == WINDOWS && lastFocusLostAt < autoHideEnabledAt) {
504+
return
505+
}
506+
if (shouldShowWindow) { // Only hide if currently visible
507+
requestHide()
508+
}
492509
}
493-
requestHide()
494510
}
495-
}
496511

497-
// macOS-specific outside click detection
498-
val macWatcher = if (getOperatingSystem() == MACOS) {
499-
MacOutsideClickWatcher(
500-
windowSupplier = { window },
501-
onOutsideClick = { invokeLater { requestHide() } }
502-
).also { it.start() }
503-
} else null
504-
505-
// Linux-specific outside click detection
506-
val linuxWatcher = if (getOperatingSystem() == OperatingSystem.LINUX) {
507-
LinuxOutsideClickWatcher(
508-
windowSupplier = { window },
509-
onOutsideClick = { invokeLater { requestHide() } }
510-
).also { it.start() }
511-
} else null
512-
513-
window.addWindowFocusListener(focusListener)
514-
515-
// Cleanup on disposal
516-
onDispose {
517-
window.removeWindowFocusListener(focusListener)
518-
macWatcher?.stop()
519-
linuxWatcher?.stop()
512+
// Platform-specific outside click detection
513+
val macWatcher = if (getOperatingSystem() == MACOS) {
514+
MacOutsideClickWatcher(
515+
windowSupplier = { window },
516+
onOutsideClick = {
517+
if (shouldShowWindow) { // Only hide if currently visible
518+
invokeLater { requestHide() }
519+
}
520+
}
521+
).also { it.start() }
522+
} else null
523+
524+
val linuxWatcher = if (getOperatingSystem() == OperatingSystem.LINUX) {
525+
LinuxOutsideClickWatcher(
526+
windowSupplier = { window },
527+
onOutsideClick = {
528+
if (shouldShowWindow) { // Only hide if currently visible
529+
invokeLater { requestHide() }
530+
}
531+
}
532+
).also { it.start() }
533+
} else null
534+
535+
window.addWindowFocusListener(focusListener)
536+
537+
// Cleanup on disposal
538+
onDispose {
539+
window.removeWindowFocusListener(focusListener)
540+
macWatcher?.stop()
541+
linuxWatcher?.stop()
542+
}
520543
}
521-
}
522544

523-
// Handle visibility state changes
524-
LaunchedEffect(shouldShowWindow) {
525-
if (shouldShowWindow) {
526-
// Window is becoming visible
527-
runCatching { WindowVisibilityMonitor.recompute() }
528-
invokeLater {
529-
try {
530-
// Bring window to front and request focus
531-
window.toFront()
532-
window.requestFocus()
533-
window.requestFocusInWindow()
534-
} catch (_: Throwable) {
545+
// React to visibility changes
546+
LaunchedEffect(shouldShowWindow) {
547+
if (shouldShowWindow) {
548+
// Window is becoming visible
549+
runCatching { WindowVisibilityMonitor.recompute() }
550+
invokeLater {
551+
try {
552+
// Bring window to front and request focus
553+
window.toFront()
554+
window.requestFocus()
555+
window.requestFocusInWindow()
556+
} catch (_: Throwable) {
557+
}
535558
}
559+
} else {
560+
// Window is becoming hidden
561+
runCatching { WindowVisibilityMonitor.recompute() }
536562
}
537-
} else {
538-
// Window is becoming hidden
539-
runCatching { WindowVisibilityMonitor.recompute() }
540563
}
541-
}
542564

543-
// Content wrapper with fade animation
544-
Box(
545-
modifier = Modifier
546-
.fillMaxSize()
547-
.alpha(alpha) // Apply fade animation
548-
) {
549-
// User content - always mounted to preserve state
550-
content()
565+
// Content wrapper with fade animation
566+
Box(
567+
modifier = Modifier
568+
.fillMaxSize()
569+
.alpha(alpha) // Apply fade animation
570+
) {
571+
// Content is always mounted, preserving all states
572+
content()
573+
}
551574
}
552575
}
553576
}

0 commit comments

Comments
 (0)