Travis Griggs
04/25/2023, 6:53 PMdata class ModalPeekhole(val bounds: Rect, val shape: Shape = RectangleShape)
val LocalPeekhole = compositionLocalOf { mutableStateOf<ModalPeekhole?>(null) }
@SuppressLint("UnrememberedMutableState")
@Composable
fun ModalPeekShield(
modifier: Modifier = Modifier,
color: Color = Color.Black.copy(alpha = 0.5f),
content: @Composable BoxScope.() -> Unit
) {
CompositionLocalProvider(LocalPeekhole provides mutableStateOf(null)) {
Box(modifier = modifier.fillMaxSize()) {
content()
// AnimatedVisibility(
// visible = LocalPeekhole.current.value != null,
// enter = fadeIn(tween(1000)),
// exit = fadeOut(
// tween(1000)
// )
// ) {
LocalPeekhole.current.value?.let { hole ->
ScrimCover(hole, color)
// }
}
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun ScrimCover(
hole: ModalPeekhole, color: Color
) {
val outline = hole.shape.createOutline(
size = hole.bounds.size,
layoutDirection = LocalLayoutDirection.current,
density = LocalDensity.current
)
val cutout = Path().apply {
addOutline(outline)
translate(Offset(hole.bounds.left, <http://hole.bounds.top|hole.bounds.top>))
}
Canvas(modifier = Modifier
.fillMaxSize()
.pointerInteropFilter { event ->
val center = Offset(event.x, event.y)
val peephole = Path().apply { addRect(Rect(center = center, 1f)) }
val isOutside = Path.combine(PathOperation.Intersect, cutout, peephole).isEmpty
isOutside
}) {
val full = Path().apply { addRect(Rect(Offset.Zero, size)) }
val cover = Path.combine(operation = PathOperation.Difference, path1 = full, path2 = cutout)
drawPath(path = cover, color = color, style = Fill)
}
}
// Everything past here is just for demo/example
@Composable
private fun ExperimentScreen(modifier: Modifier = Modifier) {
ModalPeekShield(modifier = modifier, color = Color.hsv(0.2f, 0.2f, 1f, alpha = 0.75f)) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(start = 20.dp)
.background(color = Color.Red.copy(alpha = 0.5f))
) {
Level1()
}
}
}
@Composable
private fun Level1() {
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = 30.dp)
.background(color = Color.Blue.copy(alpha = 0.5f))
) {
Level2()
}
}
@Composable
private fun Level2() {
Box(
modifier = Modifier
.fillMaxSize()
.padding(end = 40.dp)
.background(color = Color.Green.copy(alpha = 0.5f))
) {
Level3()
}
}
@Composable
private fun Level3() {
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = 50.dp)
.background(color = Color.Yellow.copy(alpha = 0.5f))
) {
SomeToggles()
}
}
@Composable
private fun SomeToggles() {
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally
) {
LocalToggle(1)
LocalToggle(2)
LocalToggle(3)
LocalToggle(4)
}
}
}
@Composable
private fun LocalToggle(index: Int) {
var selected by remember { mutableStateOf(false) }
var frame by remember { mutableStateOf(Rect.Zero) }
val peekhole = LocalPeekhole.current
RadioButton(selected = selected,
onClick = {
selected = selected.not()
peekhole.value =
selected.opt({ ModalPeekhole(bounds = frame, shape = CircleShape) }, null)
},
modifier = Modifier
.background(Color.hsv(index.toFloat() / 4f * 360, 1f, 1f))
.width(150.dp)
.height(150.dp)
.onGloballyPositioned { layoutCoordinates ->
frame = layoutCoordinates.boundsInRoot()
})
}
@Preview(showBackground = true)
@Composable
private fun ExperimentScreenPreview() {
StatusDemoTheme {
ExperimentScreen()
}
}
1. I wish I could get the scrim to fade in/fade out. But with the AnimatedVisibility present, the whole app visually freezes after the first activation
2. I was rather proud of how I accomplished the non rectangular hit detection by using a Path intersection to test touch point intersection with the peekhole
3. I had to use pointerInteropFilter
to selectively filter the MotionEvents. I would love to know how to do this with the newer stock coroutine stuff, but that wasn't clear to me at all.