Skip to content

Commit 1b5d5b5

Browse files
committed
implement BottomSheetDialog composable and BottomSheetDialogProperties
- based on Dialog composable - replace Android Dialog with BottomSheetDialog from material-components-android - set transparent theme for dialog - enable set navigationBarColor by property
1 parent ebd3fd8 commit 1b5d5b5

File tree

4 files changed

+373
-7
lines changed

4 files changed

+373
-7
lines changed

bottomsheetdialog-compose/build.gradle

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,18 @@ android {
2727
kotlinOptions {
2828
jvmTarget = '1.8'
2929
}
30+
buildFeatures {
31+
buildConfig = false
32+
compose = true
33+
}
34+
composeOptions {
35+
kotlinCompilerExtensionVersion = "1.2.0-beta02"
36+
}
3037
}
3138

3239
dependencies {
33-
34-
implementation 'androidx.core:core-ktx:1.7.0'
35-
implementation 'androidx.appcompat:appcompat:1.4.2'
40+
implementation 'androidx.compose.ui:ui:1.2.0-beta02'
41+
implementation 'androidx.core:core-ktx:1.8.0'
3642
implementation 'com.google.android.material:material:1.6.1'
3743
testImplementation 'junit:junit:4.13.2'
3844
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,2 @@
11
<?xml version="1.0" encoding="utf-8"?>
2-
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3-
package="com.holix.android.bottomsheetdialog.compose">
4-
5-
</manifest>
2+
<manifest package="com.holix.android.bottomsheetdialog.compose" />
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
package com.holix.android.bottomsheetdialog.compose
2+
3+
import android.app.Dialog
4+
import android.content.Context
5+
import android.graphics.Outline
6+
import android.view.*
7+
import androidx.compose.runtime.*
8+
import androidx.compose.runtime.saveable.rememberSaveable
9+
import androidx.compose.ui.Modifier
10+
import androidx.compose.ui.graphics.Color
11+
import androidx.compose.ui.graphics.toArgb
12+
import androidx.compose.ui.layout.Layout
13+
import androidx.compose.ui.platform.*
14+
import androidx.compose.ui.semantics.dialog
15+
import androidx.compose.ui.semantics.semantics
16+
import androidx.compose.ui.unit.Density
17+
import androidx.compose.ui.unit.LayoutDirection
18+
import androidx.compose.ui.unit.dp
19+
import androidx.compose.ui.window.Dialog
20+
import androidx.compose.ui.window.DialogProperties
21+
import androidx.compose.ui.window.SecureFlagPolicy
22+
import androidx.lifecycle.ViewTreeLifecycleOwner
23+
import androidx.lifecycle.ViewTreeViewModelStoreOwner
24+
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
25+
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
26+
import com.google.android.material.bottomsheet.BottomSheetBehavior
27+
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
28+
import com.google.android.material.bottomsheet.BottomSheetDialog
29+
import java.util.*
30+
31+
32+
/**
33+
* Properties used to customize the behavior of a [Dialog].
34+
*
35+
* @property dismissOnBackPress whether the dialog can be dismissed by pressing the back button.
36+
* If true, pressing the back button will call onDismissRequest.
37+
* @property dismissOnClickOutside whether the dialog can be dismissed by clicking outside the
38+
* dialog's bounds. If true, clicking outside the dialog will call onDismissRequest.
39+
* @property securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the
40+
* dialog's window.
41+
* @property usePlatformDefaultWidth Whether the width of the dialog's content should be limited to
42+
* the platform default, which is smaller than the screen width.
43+
*/
44+
@Immutable
45+
class BottomSheetDialogProperties constructor(
46+
val dismissOnBackPress: Boolean = true,
47+
val dismissOnClickOutside: Boolean = true,
48+
val securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit,
49+
val navigationBarColor: Color = Color.Unspecified
50+
) {
51+
52+
override fun equals(other: Any?): Boolean {
53+
if (this === other) return true
54+
if (other !is DialogProperties) return false
55+
56+
if (dismissOnBackPress != other.dismissOnBackPress) return false
57+
if (dismissOnClickOutside != other.dismissOnClickOutside) return false
58+
if (securePolicy != other.securePolicy) return false
59+
60+
return true
61+
}
62+
63+
override fun hashCode(): Int {
64+
var result = dismissOnBackPress.hashCode()
65+
result = 31 * result + dismissOnClickOutside.hashCode()
66+
result = 31 * result + securePolicy.hashCode()
67+
return result
68+
}
69+
}
70+
71+
/**
72+
* Opens a dialog with the given content.
73+
*
74+
* The dialog is visible as long as it is part of the composition hierarchy.
75+
* In order to let the user dismiss the Dialog, the implementation of [onDismissRequest] should
76+
* contain a way to remove to remove the dialog from the composition hierarchy.
77+
*
78+
* Example usage:
79+
*
80+
* @sample androidx.compose.ui.samples.DialogSample
81+
*
82+
* @param onDismissRequest Executes when the user tries to dismiss the dialog.
83+
* @param properties [DialogProperties] for further customization of this dialog's behavior.
84+
* @param content The content to be displayed inside the dialog.
85+
*/
86+
@Composable
87+
fun BottomSheetDialog(
88+
onDismissRequest: () -> Unit,
89+
properties: BottomSheetDialogProperties = BottomSheetDialogProperties(),
90+
content: @Composable () -> Unit
91+
) {
92+
val view = LocalView.current
93+
val density = LocalDensity.current
94+
val layoutDirection = LocalLayoutDirection.current
95+
val composition = rememberCompositionContext()
96+
val currentContent by rememberUpdatedState(content)
97+
val dialogId = rememberSaveable { UUID.randomUUID() }
98+
val dialog = remember(view, density) {
99+
BottomSheetDialogWrapper(
100+
onDismissRequest,
101+
properties,
102+
view,
103+
layoutDirection,
104+
density,
105+
dialogId
106+
).apply {
107+
setContent(composition) {
108+
// TODO(b/159900354): draw a scrim and add margins around the Compose Dialog, and
109+
// consume clicks so they can't pass through to the underlying UI
110+
BottomSheetDialogLayout(
111+
Modifier.semantics { dialog() },
112+
) {
113+
currentContent()
114+
}
115+
}
116+
}
117+
}
118+
119+
DisposableEffect(dialog) {
120+
dialog.show()
121+
122+
onDispose {
123+
dialog.dismiss()
124+
dialog.disposeComposition()
125+
}
126+
}
127+
128+
SideEffect {
129+
dialog.updateParameters(
130+
onDismissRequest = onDismissRequest,
131+
properties = properties,
132+
layoutDirection = layoutDirection
133+
)
134+
}
135+
}
136+
137+
/**
138+
* Provides the underlying window of a dialog.
139+
*
140+
* Implemented by dialog's root layout.
141+
*/
142+
interface DialogWindowProvider {
143+
val window: Window
144+
}
145+
146+
@Suppress("ViewConstructor")
147+
private class BottomSheetDialogLayout(
148+
context: Context,
149+
override val window: Window
150+
) : AbstractComposeView(context), DialogWindowProvider {
151+
private var content: @Composable () -> Unit by mutableStateOf({})
152+
153+
override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
154+
private set
155+
156+
fun setContent(parent: CompositionContext, content: @Composable () -> Unit) {
157+
setParentCompositionContext(parent)
158+
this.content = content
159+
shouldCreateCompositionOnAttachedToWindow = true
160+
createComposition()
161+
}
162+
163+
@Composable
164+
override fun Content() {
165+
content()
166+
}
167+
}
168+
169+
private class BottomSheetDialogWrapper(
170+
private var onDismissRequest: () -> Unit,
171+
private var properties: BottomSheetDialogProperties,
172+
private val composeView: View,
173+
layoutDirection: LayoutDirection,
174+
density: Density,
175+
dialogId: UUID
176+
) : BottomSheetDialog(
177+
/**
178+
* [Window.setClipToOutline] is only available from 22+, but the style attribute exists on 21.
179+
* So use a wrapped context that sets this attribute for compatibility back to 21.
180+
*/
181+
ContextThemeWrapper(composeView.context, R.style.TransparentBottomSheetTheme)
182+
),
183+
ViewRootForInspector {
184+
private val bottomSheetDialogLayout: BottomSheetDialogLayout
185+
186+
private val maxSupportedElevation = 30.dp
187+
188+
override val subCompositionView: AbstractComposeView get() = bottomSheetDialogLayout
189+
190+
init {
191+
val window = window ?: error("Dialog has no window")
192+
window.requestFeature(Window.FEATURE_NO_TITLE)
193+
window.setBackgroundDrawableResource(android.R.color.transparent)
194+
bottomSheetDialogLayout = BottomSheetDialogLayout(context, window).apply {
195+
// Set unique id for AbstractComposeView. This allows state restoration for the state
196+
// defined inside the Dialog via rememberSaveable()
197+
setTag(androidx.compose.ui.R.id.compose_view_saveable_id_tag, "Dialog:$dialogId")
198+
// Enable children to draw their shadow by not clipping them
199+
clipChildren = false
200+
// Allocate space for elevation
201+
with(density) { elevation = maxSupportedElevation.toPx() }
202+
// Simple outline to force window manager to allocate space for shadow.
203+
// Note that the outline affects clickable area for the dismiss listener. In case of
204+
// shapes like circle the area for dismiss might be to small (rectangular outline
205+
// consuming clicks outside of the circle).
206+
outlineProvider = object : ViewOutlineProvider() {
207+
override fun getOutline(view: View, result: Outline) {
208+
result.setRect(0, 0, view.width, view.height)
209+
// We set alpha to 0 to hide the view's shadow and let the composable to draw
210+
// its own shadow. This still enables us to get the extra space needed in the
211+
// surface.
212+
result.alpha = 0f
213+
}
214+
}
215+
}
216+
this.behavior.addBottomSheetCallback(
217+
object : BottomSheetBehavior.BottomSheetCallback() {
218+
override fun onSlide(bottomSheet: View, slideOffset: Float) {
219+
}
220+
221+
override fun onStateChanged(bottomSheet: View, newState: Int) {
222+
if (newState == STATE_HIDDEN) {
223+
onDismissRequest()
224+
}
225+
}
226+
}
227+
)
228+
229+
/**
230+
* Disables clipping for [this] and all its descendant [ViewGroup]s until we reach a
231+
* [BottomSheetDialogLayout] (the [ViewGroup] containing the Compose hierarchy).
232+
*/
233+
fun ViewGroup.disableClipping() {
234+
clipChildren = false
235+
if (this is BottomSheetDialogLayout) return
236+
for (i in 0 until childCount) {
237+
(getChildAt(i) as? ViewGroup)?.disableClipping()
238+
}
239+
}
240+
241+
// Turn of all clipping so shadows can be drawn outside the window
242+
(window.decorView as? ViewGroup)?.disableClipping()
243+
setContentView(bottomSheetDialogLayout)
244+
ViewTreeLifecycleOwner.set(bottomSheetDialogLayout, ViewTreeLifecycleOwner.get(composeView))
245+
ViewTreeViewModelStoreOwner.set(bottomSheetDialogLayout, ViewTreeViewModelStoreOwner.get(composeView))
246+
bottomSheetDialogLayout.setViewTreeSavedStateRegistryOwner(
247+
composeView.findViewTreeSavedStateRegistryOwner()
248+
)
249+
250+
// Initial setup
251+
updateParameters(onDismissRequest, properties, layoutDirection)
252+
}
253+
254+
private fun setLayoutDirection(layoutDirection: LayoutDirection) {
255+
bottomSheetDialogLayout.layoutDirection = when (layoutDirection) {
256+
LayoutDirection.Ltr -> android.util.LayoutDirection.LTR
257+
LayoutDirection.Rtl -> android.util.LayoutDirection.RTL
258+
}
259+
}
260+
261+
// TODO(b/159900354): Make the Android Dialog full screen and the scrim fully transparent
262+
263+
fun setContent(parentComposition: CompositionContext, children: @Composable () -> Unit) {
264+
bottomSheetDialogLayout.setContent(parentComposition, children)
265+
}
266+
267+
private fun setSecurePolicy(securePolicy: SecureFlagPolicy) {
268+
val secureFlagEnabled =
269+
securePolicy.shouldApplySecureFlag(composeView.isFlagSecureEnabled())
270+
window!!.setFlags(
271+
if (secureFlagEnabled) {
272+
WindowManager.LayoutParams.FLAG_SECURE
273+
} else {
274+
WindowManager.LayoutParams.FLAG_SECURE.inv()
275+
},
276+
WindowManager.LayoutParams.FLAG_SECURE
277+
)
278+
}
279+
280+
private fun setNavigationBarColor(color: Color) {
281+
if (color != Color.Unspecified) {
282+
window!!.navigationBarColor = color.toArgb()
283+
}
284+
}
285+
286+
fun updateParameters(
287+
onDismissRequest: () -> Unit,
288+
properties: BottomSheetDialogProperties,
289+
layoutDirection: LayoutDirection
290+
) {
291+
this.onDismissRequest = onDismissRequest
292+
this.properties = properties
293+
setSecurePolicy(properties.securePolicy)
294+
setLayoutDirection(layoutDirection)
295+
setCanceledOnTouchOutside(properties.dismissOnClickOutside)
296+
setNavigationBarColor(properties.navigationBarColor)
297+
}
298+
299+
fun disposeComposition() {
300+
bottomSheetDialogLayout.disposeComposition()
301+
}
302+
303+
override fun cancel() {
304+
onDismissRequest()
305+
}
306+
307+
override fun onBackPressed() {
308+
if (properties.dismissOnBackPress) {
309+
onDismissRequest()
310+
}
311+
}
312+
}
313+
314+
@Composable
315+
private fun BottomSheetDialogLayout(
316+
modifier: Modifier = Modifier,
317+
content: @Composable () -> Unit
318+
) {
319+
Layout(
320+
content = content,
321+
modifier = modifier
322+
) { measurables, constraints ->
323+
val placeables = measurables.map { it.measure(constraints) }
324+
val width = placeables.maxByOrNull { it.width }?.width ?: constraints.minWidth
325+
val height = placeables.maxByOrNull { it.height }?.height ?: constraints.minHeight
326+
layout(width, height) {
327+
placeables.forEach { it.placeRelative(0, 0) }
328+
}
329+
}
330+
}
331+
332+
fun View.isFlagSecureEnabled(): Boolean {
333+
val windowParams = rootView.layoutParams as? WindowManager.LayoutParams
334+
if (windowParams != null) {
335+
return (windowParams.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0
336+
}
337+
return false
338+
}
339+
340+
fun SecureFlagPolicy.shouldApplySecureFlag(isSecureFlagSetOnParent: Boolean): Boolean {
341+
return when (this) {
342+
SecureFlagPolicy.SecureOff -> false
343+
SecureFlagPolicy.SecureOn -> true
344+
SecureFlagPolicy.Inherit -> isSecureFlagSetOnParent
345+
}
346+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<resources>
3+
<style name="TransparentBottomSheetTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
4+
<item name="bottomSheetDialogTheme">@style/TransparentBottomSheetDialog</item>
5+
</style>
6+
7+
<style name="TransparentBottomSheetDialog" parent="ThemeOverlay.MaterialComponents.BottomSheetDialog">
8+
<item name="bottomSheetStyle">@style/TransparentBottomSheet</item>
9+
<item name="android:windowClipToOutline">false</item>
10+
<item name="android:windowBackground">@android:color/transparent</item>
11+
<item name="android:background">@null</item>
12+
</style>
13+
14+
<style name="TransparentBottomSheet" parent="Widget.MaterialComponents.BottomSheet">
15+
<item name="backgroundTint">@android:color/transparent</item>
16+
</style>
17+
</resources>

0 commit comments

Comments
 (0)