Skip to content

Instantly share code, notes, and snippets.

@grandstaish
Created January 27, 2021 20:20
Show Gist options
  • Save grandstaish/477a6f7103c949cac9204151383a2b19 to your computer and use it in GitHub Desktop.
Save grandstaish/477a6f7103c949cac9204151383a2b19 to your computer and use it in GitHub Desktop.
BottomSheetDialog.kt
package com.monzo.compose
import android.app.Dialog
import android.content.Context
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionReference
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.ExperimentalComposeApi
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.compositionReference
import androidx.compose.runtime.emptyContent
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.gesture.nestedscroll.nestedScroll
import androidx.compose.ui.gesture.scrollorientationlocking.Orientation
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.AmbientView
import androidx.compose.ui.semantics.dialog
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewTreeLifecycleOwner
import androidx.lifecycle.ViewTreeViewModelStoreOwner
import androidx.savedstate.ViewTreeSavedStateRegistryOwner
import com.monzo.compose.BottomSheetLayoutSlot.Coordinator
import com.monzo.compose.BottomSheetLayoutSlot.Sheet
import dev.chrisbanes.accompanist.insets.statusBarsPadding
import kotlin.math.max
import kotlin.math.roundToInt
import kotlinx.coroutines.flow.collect
@Composable
@OptIn(ExperimentalComposeApi::class)
fun BottomSheetDialog(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
val state = rememberSwipeableState(BottomSheetState.Collapsed)
LaunchedEffect(state) {
// When this composable first composes, animate the sheet to the expanded (or half expanded) state.
state.animateTo(
if (state.anchors.values.contains(BottomSheetState.HalfExpanded)) {
BottomSheetState.HalfExpanded
} else {
BottomSheetState.Expanded
}
)
// Wait until the sheet returns to the collapsed state, and then call onDismissRequest.
snapshotFlow { state.value to state.isAnimationRunning }
.collect { (bottomSheetState, isAnimationRunning) ->
if (!isAnimationRunning && bottomSheetState == BottomSheetState.Collapsed) {
onDismissRequest()
}
}
}
FullscreenDialog(
onBackPress = { state.animateTo(BottomSheetState.Collapsed) }
) {
BottomSheetLayout(
modifier = modifier.fillMaxSize().statusBarsPadding(),
onTapOutside = { state.animateTo(BottomSheetState.Collapsed) },
state = state
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.nestedScroll(state.PreUpPostDownNestedScrollConnection)
.offset { IntOffset(0, state.offset.value.roundToInt()) },
color = Theme.backgrounds.secondary,
shape = RoundedCornerShape(topLeft = 12.dp, topRight = 12.dp),
elevation = 16.dp
) {
Column(content = content)
}
}
}
}
private enum class BottomSheetState {
Collapsed,
HalfExpanded,
Expanded
}
@Composable
private fun BottomSheetLayout(
modifier: Modifier,
onTapOutside: () -> Unit,
state: SwipeableState<BottomSheetState>,
content: @Composable () -> Unit
) {
// Measures the sheet content to get its height, then passes it to the coordinator composable so that it can
// properly set up the swipeable modifier.
SubcomposeLayout(modifier) { constraints ->
val sheetPlaceable = subcompose(Sheet, content).first()
.measure(constraints.copy(minWidth = 0, minHeight = 0))
val sheetHeight = sheetPlaceable.height.toFloat()
val coordinatorPlaceable = subcompose(Coordinator) {
SheetCoordinator(constraints, sheetHeight, onTapOutside, state)
}.first().measure(constraints)
layout(coordinatorPlaceable.width, coordinatorPlaceable.height) {
coordinatorPlaceable.placeRelative(0, 0)
sheetPlaceable.placeRelative(0, 0)
}
}
}
@Composable
private fun SheetCoordinator(
constraints: Constraints,
sheetHeight: Float,
onTapOutside: () -> Unit,
state: SwipeableState<BottomSheetState>
) {
// If the height of the sheet is greater than 50% of the available space, also include a "half expanded"
// anchor. Otherwise, only include collapsed and expanded anchors.
val fullHeight = constraints.maxHeight.toFloat()
val anchors = if (sheetHeight < fullHeight / 2) {
mapOf(
fullHeight to BottomSheetState.Collapsed,
fullHeight - sheetHeight to BottomSheetState.Expanded
)
} else {
mapOf(
fullHeight to BottomSheetState.Collapsed,
fullHeight / 2 to BottomSheetState.HalfExpanded,
max(0f, fullHeight - sheetHeight) to BottomSheetState.Expanded
)
}
// Add a non-moving box that fills the screen. The user swipes this box to control the drawer.
// TODO(brad): this should only really drag the sheet if the gesture starts inside of the sheet bounds. Google's
// version doesn't do this correctly yet either. I'm not really sure how to achieve this yet.
Box(
Modifier
.fillMaxSize()
.swipeable(
state = state,
anchors = anchors,
orientation = Orientation.Vertical,
enabled = state.value != BottomSheetState.Collapsed,
resistance = null
)
.pointerInput {
// TODO(brad): this is not detecting tap gestures when the initial open animation is running. Why not?
detectTapGestures {
onTapOutside()
}
}
)
}
private enum class BottomSheetLayoutSlot { Sheet, Coordinator }
/**
* A dialog that covers the entire window.
*
* (Modified version of AndroidDialog.kt in Compose UI.)
*/
@Composable
private fun FullscreenDialog(
onBackPress: () -> Unit,
content: @Composable () -> Unit
) {
val view = AmbientView.current
val composition = compositionReference()
val currentContent by rememberUpdatedState(content)
val dialog = remember(view) {
DialogWrapper(view).apply {
setContent(composition) {
Box(
Modifier.semantics { dialog() }
) {
currentContent()
}
}
}
}
DisposableEffect(dialog) {
dialog.show()
onDispose {
dialog.dismiss()
dialog.disposeComposition()
}
}
SideEffect {
dialog.backPressHandler = onBackPress
}
}
private class DialogLayout(context: Context) : AbstractComposeView(context) {
private var content: @Composable () -> Unit by mutableStateOf(emptyContent())
override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
private set
fun setContent(parent: CompositionReference, content: @Composable () -> Unit) {
setParentCompositionReference(parent)
this.content = content
shouldCreateCompositionOnAttachedToWindow = true
createComposition()
}
@Composable
override fun Content() {
content()
}
}
private class DialogWrapper(composeView: View) : Dialog(composeView.context) {
lateinit var backPressHandler: () -> Unit
private val dialogLayout: DialogLayout
init {
val window = window ?: error("Dialog has no window")
window.requestFeature(Window.FEATURE_NO_TITLE)
window.setBackgroundDrawableResource(android.R.color.transparent)
dialogLayout = DialogLayout(context)
setContentView(dialogLayout)
ViewTreeLifecycleOwner.set(dialogLayout, ViewTreeLifecycleOwner.get(composeView))
ViewTreeViewModelStoreOwner.set(dialogLayout, ViewTreeViewModelStoreOwner.get(composeView))
ViewTreeSavedStateRegistryOwner.set(dialogLayout, ViewTreeSavedStateRegistryOwner.get(composeView))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val window = window!!
val decorView = window.decorView
// Draw the system bars ourselves as transparent bars. API 23+ only so we can control the system icon tints
if (Build.VERSION.SDK_INT >= 23) {
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
decorView.systemUiVisibility = decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv()
}
// Set the window to layout using the max width/height
window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
// Force the decor view to layout behind the status and navigation bars
// https://issuetracker.google.com/issues/178391051
decorView.systemUiVisibility = decorView.systemUiVisibility or (View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN)
}
fun setContent(parentComposition: CompositionReference, children: @Composable () -> Unit) {
dialogLayout.setContent(parentComposition, children)
}
fun disposeComposition() {
dialogLayout.disposeComposition()
}
override fun cancel() {
// Prevents the dialog from dismissing itself
return
}
override fun onBackPressed() {
backPressHandler()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment