Created
July 11, 2023 12:20
-
-
Save fabriciovergara/f5f2c1d2877269b37ecd5844d127f868 to your computer and use it in GitHub Desktop.
InAppNotification
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
import androidx.compose.animation.AnimatedContent | |
import androidx.compose.animation.ExperimentalAnimationApi | |
import androidx.compose.animation.core.LinearEasing | |
import androidx.compose.animation.core.animate | |
import androidx.compose.animation.core.tween | |
import androidx.compose.animation.slideInVertically | |
import androidx.compose.animation.slideOutVertically | |
import androidx.compose.animation.with | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.material.Button | |
import androidx.compose.material.DismissValue | |
import androidx.compose.material.ExperimentalMaterialApi | |
import androidx.compose.material.FractionalThreshold | |
import androidx.compose.material.MaterialTheme | |
import androidx.compose.material.Scaffold | |
import androidx.compose.material.Slider | |
import androidx.compose.material.SwipeToDismiss | |
import androidx.compose.material.Text | |
import androidx.compose.material.rememberDismissState | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.CompositionLocalProvider | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.compositionLocalOf | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.rememberCoroutineScope | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.tooling.preview.Preview | |
import androidx.compose.ui.unit.dp | |
import java.util.concurrent.TimeUnit | |
import kotlinx.coroutines.CompletableDeferred | |
import kotlinx.coroutines.CoroutineScope | |
import kotlinx.coroutines.Job | |
import kotlinx.coroutines.channels.BufferOverflow | |
import kotlinx.coroutines.delay | |
import kotlinx.coroutines.flow.MutableSharedFlow | |
import kotlinx.coroutines.job | |
import kotlinx.coroutines.launch | |
private typealias InAppNotificationContent = @Composable InAppNotificationScope.() -> Unit | |
val LocalInAppNotificationController = compositionLocalOf<InAppNotificationController> { | |
error("LocalInAppNotificationScope must be provided") | |
} | |
interface InAppNotificationScope { | |
/** | |
* Progress from 0f to 1f, indicate the countdown to hide the notification | |
*/ | |
val progress: Float | |
/** | |
* Cancel the current displaying notification. | |
* Progress value will automatically be set to 1f | |
*/ | |
fun cancel() | |
/** | |
* Pause the current displaying notification. | |
* Progress value will be frozen until resume is called again. | |
*/ | |
fun pause() | |
/** | |
* Resume current displaying notification. | |
*/ | |
fun resume() | |
} | |
interface InAppNotificationController { | |
fun show(duration: Long = TimeUnit.SECONDS.toMillis(5), content: InAppNotificationContent): Job | |
} | |
@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterialApi::class) | |
@Composable | |
fun InAppNotificationHost( | |
content: @Composable () -> Unit | |
) { | |
val coroutineScope = rememberCoroutineScope() | |
val state = remember { InAppNotificationControllerImpl(coroutineScope) } | |
val current = remember { mutableStateOf<InAppNotificationScopeImpl?>(null) } | |
LaunchedEffect(state) { | |
state.flow.collect { next -> | |
try { | |
if (next != null) { | |
val completable = CompletableDeferred<Unit>(coroutineContext.job) | |
current.value = InAppNotificationScopeImpl(coroutineScope, next, completable) | |
current.value?.resume() | |
completable.join() | |
} | |
} finally { | |
current.value = null | |
} | |
} | |
} | |
CompositionLocalProvider( | |
LocalInAppNotificationController provides state | |
) { | |
Box { | |
content() | |
AnimatedContent( | |
targetState = current.value, | |
transitionSpec = { slideInVertically { -it } with slideOutVertically { -it } } | |
) { value -> | |
val dismissState = rememberDismissState() | |
val dismissValue = dismissState.currentValue | |
LaunchedEffect(dismissValue) { | |
if (dismissValue != DismissValue.Default) { | |
delay(100) | |
value?.cancel() | |
} | |
} | |
SwipeToDismiss( | |
modifier = Modifier.fillMaxWidth(), | |
state = dismissState, | |
dismissThresholds = { FractionalThreshold(0.5f) }, | |
background = { } | |
) { | |
Box( | |
modifier = Modifier.animateEnterExit( | |
enter = slideInVertically { -it }, | |
exit = slideOutVertically { -it } | |
) | |
) { | |
value?.Render() | |
} | |
} | |
} | |
} | |
} | |
} | |
private class InAppNotificationScopeImpl( | |
private val scope: CoroutineScope, | |
private val value: InAppNotificationStateValue, | |
private val completable: CompletableDeferred<Unit> | |
) : InAppNotificationScope { | |
private var job: Job? = null | |
private val progressState = mutableStateOf(0f) | |
override val progress: Float get() = progressState.value | |
override fun cancel() { | |
job?.cancel() | |
onUpdateProgress(1f) | |
} | |
override fun pause() { | |
job?.cancel() | |
} | |
override fun resume() { | |
if (job?.isActive == true) { | |
return | |
} | |
if (progressState.value == 1f) { | |
return | |
} | |
job = scope.launch { | |
val remainDuration = ((1f - progressState.value) * value.duration).toInt() | |
val spec = tween<Float>(delayMillis = 0, durationMillis = remainDuration, easing = LinearEasing) | |
animate(initialValue = progressState.value, targetValue = 1f, initialVelocity = 0f, animationSpec = spec) { value, _ -> | |
onUpdateProgress(value) | |
} | |
} | |
} | |
private fun onUpdateProgress(value: Float) { | |
progressState.value = value | |
if (value == 1f) { | |
completable.complete(Unit) | |
} | |
} | |
@Composable | |
fun Render() { | |
value.content(this) | |
} | |
} | |
private class InAppNotificationControllerImpl( | |
private val scope: CoroutineScope | |
) : InAppNotificationController { | |
val flow = MutableSharedFlow<InAppNotificationStateValue?>(onBufferOverflow = BufferOverflow.SUSPEND) | |
override fun show(duration: Long, content: InAppNotificationContent): Job = scope.launch { | |
flow.emit(InAppNotificationStateValue(content, duration)) | |
} | |
} | |
private data class InAppNotificationStateValue( | |
val content: InAppNotificationContent, | |
val duration: Long | |
) | |
@Preview | |
@Composable | |
fun Usage() { | |
MaterialTheme { | |
InAppNotificationHost { | |
Scaffold { | |
Box( | |
modifier = Modifier | |
.padding(it) | |
.fillMaxSize() | |
) { | |
val inAppNotificationController = LocalInAppNotificationController.current | |
Button( | |
modifier = Modifier.align(Alignment.Center), | |
onClick = { | |
inAppNotificationController.show { | |
Column( | |
modifier = Modifier | |
.fillMaxWidth() | |
.background(Color.Black) | |
.padding(8.dp) | |
) { | |
Button( | |
onClick = { cancel() } | |
) { | |
Text("Ok") | |
} | |
Slider( | |
modifier = Modifier | |
.fillMaxWidth(), | |
value = progress, | |
onValueChange = {} | |
) | |
} | |
} | |
} | |
) { | |
Text("Show InAppNotification") | |
} | |
} | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Screen.Recording.2023-07-11.at.14.20.11.mov