Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,25 @@ package com.sdds.compose.uikit.fixtures.stories.toast

import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import com.sdds.compose.sandbox.ComposeBaseStory
import com.sdds.compose.uikit.Button
import com.sdds.compose.uikit.Icon
import com.sdds.compose.uikit.Modal
import com.sdds.compose.uikit.ModalGravity
import com.sdds.compose.uikit.Text
import com.sdds.compose.uikit.Toast
import com.sdds.compose.uikit.ToastStyle
Expand All @@ -35,6 +45,8 @@ data class ToastUiState(
val hasContentEnd: Boolean = true,
val position: OverlayPosition = OverlayPosition.BottomCenter,
val autoDismiss: Boolean = true,
val showViaModal: Boolean = false,
val modalGravity: ModalGravity = ModalGravity.Center,
) : UiState {

override fun updateVariant(appearance: String, variant: String): UiState {
Expand All @@ -55,27 +67,35 @@ object ToastStory : ComposeBaseStory<ToastUiState, ToastStyle>(
state: ToastUiState,
) {
val overlayManager = LocalOverlayManager.current
Button(
modifier = Modifier.align(Alignment.Center),
label = "show",
onClick = {
overlayManager.showToast(
position = state.position,
durationMillis = OverlayManager.OVERLAY_DURATION_SLOW_MILLIS
.takeIf { state.autoDismiss },
) {
Toast(
style = style,
contentStart = getContentStart(state.hasContentStart),
contentEnd = getContentEnd(state.hasContentEnd) {
overlayManager.remove(it)
},
if (state.showViaModal) {
ToastViaModal(
state = state,
style = style,
overlayManager = overlayManager,
)
} else {
Button(
modifier = Modifier.align(Alignment.Center),
label = "show",
onClick = {
overlayManager.showToast(
position = state.position,
durationMillis = OverlayManager.OVERLAY_DURATION_SLOW_MILLIS
.takeIf { state.autoDismiss },
) {
Text(state.text)
Toast(
style = style,
contentStart = getContentStart(state.hasContentStart),
contentEnd = getContentEnd(state.hasContentEnd) {
overlayManager.remove(it)
},
) {
Text(state.text)
}
}
}
},
)
},
)
}
}

@Composable
Expand Down Expand Up @@ -113,6 +133,68 @@ object ToastStory : ComposeBaseStory<ToastUiState, ToastStyle>(
}
}

@Composable
private fun BoxScope.ToastViaModal(
state: ToastUiState,
style: ToastStyle,
overlayManager: OverlayManager,
) {
var showDialog by remember { mutableStateOf(false) }
if (state.showViaModal) {
Button(
modifier = Modifier.align(Alignment.Center),
label = "show dialog",
onClick = { showDialog = true },
)
if (showDialog) {
Modal(
show = true,
onDismissRequest = { showDialog = false },
modifier = Modifier.width(300.dp),
gravity = state.modalGravity,
dialogProperties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false,
),
hasClose = true,
edgeToEdge = true,
dimBackground = false,
useNativeBlackout = false,
closeIcon = painterResource(R.drawable.ic_close_24),
) {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Button(
modifier = Modifier.align(Alignment.Center),
label = "show toast",
onClick = {
overlayManager.showToast(
onDismiss = {},
position = state.position,
durationMillis = OverlayManager.OVERLAY_DURATION_SLOW_MILLIS
.takeIf { state.autoDismiss },
) {
Toast(
style = style,
contentStart = getContentStart(state.hasContentStart),
contentEnd = getContentEnd(state.hasContentEnd) {
overlayManager.remove(it)
},
) {
Text(state.text)
}
}
},
)
}
}
}
}
}

private fun getContentStart(hasContentStart: Boolean): @Composable (() -> Unit)? {
return if (hasContentStart) {
@Composable { Icon(painter = painterResource(R.drawable.ic_shazam_16), "") }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.sdds.compose.uikit.internal.modal

import android.view.View
import androidx.compose.runtime.staticCompositionLocalOf
import java.lang.ref.WeakReference

internal val LocalDialogWindowId = staticCompositionLocalOf<String?> { null }

internal object DialogWindowRegistry {

private data class WindowEntry(
val windowId: String,
val decorViewRef: WeakReference<View>,
)

private val windows = mutableListOf<WindowEntry>()

fun register(windowId: String, decorView: View) = synchronized(windows) {
cleanupLocked()
windows.removeAll { it.windowId == windowId }
windows += WindowEntry(windowId, WeakReference(decorView))
}

fun unregister(windowId: String) = synchronized(windows) {
windows.removeAll { it.windowId == windowId }
}

fun findDecorViewBelow(windowId: String): View? = synchronized(windows) {
cleanupLocked()
val currentIndex = windows.indexOfLast { it.windowId == windowId }
if (currentIndex <= 0) return null
windows.subList(0, currentIndex)
.asReversed()
.firstNotNullOfOrNull { it.decorViewRef.get() }
}

private fun cleanupLocked() {
windows.removeAll { entry ->
val decorView = entry.decorViewRef.get()
decorView == null || !decorView.isAttachedToWindow || decorView.windowToken == null
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import android.view.ViewGroup
import android.view.WindowManager
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.isSpecified
Expand All @@ -15,6 +18,7 @@ import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import com.sdds.compose.uikit.px
import java.util.UUID

@Composable
internal fun EdgeToEdgeDialog(
Expand All @@ -29,12 +33,32 @@ internal fun EdgeToEdgeDialog(
lightAppearance: Boolean = !isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val windowId = remember { UUID.randomUUID().toString() }
Dialog(
onDismissRequest = onDismissRequest,
properties = dialogProperties.ensureCorrectProperties(edgeToEdge),
) {
RegisterDialogWindow(windowId)
ConfigureWindow(edgeToEdge, useNativeBlackout, blurRadius, lightAppearance)
content()
CompositionLocalProvider(LocalDialogWindowId provides windowId) {
content()
}
}
}

@Composable
private fun RegisterDialogWindow(windowId: String) {
val localView = LocalView.current
val dialogWindowProvider = localView.parent as? DialogWindowProvider
val decorView = dialogWindowProvider?.window?.decorView

DisposableEffect(windowId, decorView) {
if (decorView != null) {
DialogWindowRegistry.register(windowId, decorView)
}
onDispose {
DialogWindowRegistry.unregister(windowId)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package com.sdds.compose.uikit.overlay

import android.graphics.Rect
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.Window
import android.view.WindowManager
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.MutableTransitionState
Expand All @@ -15,6 +19,7 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
Expand All @@ -36,9 +41,12 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import com.sdds.compose.uikit.internal.SwipeToDismissBox
import com.sdds.compose.uikit.internal.modal.DialogWindowRegistry
import com.sdds.compose.uikit.internal.modal.EdgeToEdgeDialog
import com.sdds.compose.uikit.internal.modal.LocalDialogWindowId
import com.sdds.compose.uikit.internal.rememberSwipeToDismissBoxState
import com.sdds.compose.uikit.overlay.OverlayDismissDirection.DismissToEnd
import com.sdds.compose.uikit.overlay.OverlayDismissDirection.DismissToStart
Expand Down Expand Up @@ -245,25 +253,31 @@ private fun OverlayPopup(
val alignment = position.toAlignment()
val rootView = LocalView.current.rootView

Popup(
alignment = alignment,
properties = PopupProperties(
focusable = isFocusable,
excludeFromSystemGesture = false,
dismissOnClickOutside = false,
),
EdgeToEdgeDialog(
onDismissRequest = onDismissRequest,
useNativeBlackout = false,
dialogProperties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = isFocusable,
),
) {
val dialogView = LocalView.current.rootView
val dialogView = LocalView.current
val currentWindowId = LocalDialogWindowId.current
val newOffset = remember(position, spacing, offset) {
offset.ensureCorrectPosition(position, spacing)
}
LaunchedEffect(dialogView, rootView) {
dialogView.enablePassthroughTouch(rootView)
LaunchedEffect(dialogView, currentWindowId, position, isFocusable, rootView) {
val dialogWindowProvider = dialogView.parent as? DialogWindowProvider
dialogWindowProvider?.window?.ensureCorrect(position, isFocusable)
dialogView.rootView.enablePassthroughTouch {
currentWindowId?.let(DialogWindowRegistry::findDecorViewBelow) ?: rootView
}
}

Box(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.systemBarsPadding(),
contentAlignment = alignment,
) {
Column(
Expand All @@ -278,14 +292,35 @@ private fun OverlayPopup(
}

@Suppress("ClickableViewAccessibility")
private fun View.enablePassthroughTouch(decorView: View) {
private fun View.enablePassthroughTouch(decorViewProvider: () -> View?) {
setOnTouchListener { v, event ->
val decorView = decorViewProvider()
if (decorView == null) return@setOnTouchListener false
val anchorLocation = decorView.getScreenRect()
val listLocation = v.getScreenRect()
val offsetX = (listLocation.left - anchorLocation.left).toFloat()
val offsetY = (listLocation.top - anchorLocation.top).toFloat()
event.offsetLocation(offsetX, offsetY)
decorView.dispatchTouchEvent(event)
val transformedEvent = MotionEvent.obtain(event)
transformedEvent.offsetLocation(offsetX, offsetY)
val handled = decorView.dispatchTouchEvent(transformedEvent)
transformedEvent.recycle()
handled
}
}

@Suppress("ClickableViewAccessibility")
private fun Window.ensureCorrect(position: OverlayPosition, isFocusable: Boolean) {
setLayout(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
)
setGravity(position.toGravity())
if (!isFocusable) {
addFlags(
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH,
)
}
}

Expand All @@ -312,6 +347,18 @@ private fun OverlayPosition.toAlignment(): Alignment = when (this) {
OverlayPosition.BottomEnd -> Alignment.BottomEnd
}

private fun OverlayPosition.toGravity(): Int = when (this) {
OverlayPosition.TopStart -> Gravity.TOP or Gravity.START
OverlayPosition.TopCenter -> Gravity.TOP or Gravity.CENTER_HORIZONTAL
OverlayPosition.TopEnd -> Gravity.TOP or Gravity.END
OverlayPosition.CenterStart -> Gravity.CENTER_VERTICAL or Gravity.START
OverlayPosition.Center -> Gravity.CENTER
OverlayPosition.CenterEnd -> Gravity.CENTER_VERTICAL or Gravity.END
OverlayPosition.BottomStart -> Gravity.BOTTOM or Gravity.START
OverlayPosition.BottomCenter -> Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
OverlayPosition.BottomEnd -> Gravity.BOTTOM or Gravity.END
}

private fun OverlayPosition.toVerticalAlignment(): Alignment.Vertical = when (this) {
OverlayPosition.TopStart,
OverlayPosition.TopCenter,
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading