Created
January 27, 2021 20:20
-
-
Save grandstaish/477a6f7103c949cac9204151383a2b19 to your computer and use it in GitHub Desktop.
BottomSheetDialog.kt
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
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