Composableに穴を開けたいときがありますよね。
あんまりないと思いますけど
blendModeを使ってみる
こういうとき、Compose以前のAndroid Viewでは一旦全体を半透明の黒で塗りつぶしてから穴の部分だけ色を消すViewを実装していたと思います。
Composeでも drawBehind
というModifierを使えば同じことができそうです。
こんな感じのComposableを書くことになるでしょう。
一旦 drawRect
を使ってComposable全体をscrimColorで塗りつぶし、穴部分は blendMode
にDstOutを指定して塗りつぶした色を消去します。
@Composable
fun SpotlightScrim(
spotlightPosition: Offset,
spotlightSize: Size,
spotlightCornerRadius: CornerRadius,
modifier: Modifier = Modifier,
scrimColor: Color = Color.Black.copy(alpha = 0.5f)
) {
if (spotlightPosition.isSpecified && spotlightSize.isSpecified) {
Box(
modifier
.drawBehind {
drawRect(scrimColor)
drawRoundRect(
Color.Black,
spotlightPosition,
spotlightSize,
spotlightCornerRadius,
blendMode = BlendMode.DstOut
)
}
)
}
}
compositingStrategy
一見、blendModeがうまく効いてなくて上に黒い塗りつぶしを被せてしまったように見えます。
が、そうではありません。
compositingStrategyを指定していないことが原因です。
デフォルト値は auto
になっていて、描画命令は画面に直接作用します。「Composableに」ではなく「画面に直接」です。
この例では画面全体に半透明の黒を描画したあと、画面の指定した領域の色を消去します。結果、裏にあるComposableが描画した色も一緒くたにぶち抜いてしまいます。
つまりComposeViewの裏にあるActivityの背景色が見えている、というのが先ほどのスクリーンショットの正しい理解でしょうね。
Offscreen
意図通りの動作をさせるには CompositingStrategy.Offscreen
を指定します。
描画レイヤーが生成され、描画命令はそのレイヤーに作用するようになります。
@Composable
fun SpotlightScrim(
spotlightPosition: Offset,
spotlightSize: Size,
spotlightCornerRadius: CornerRadius,
modifier: Modifier = Modifier,
scrimColor: Color = Color.Black.copy(alpha = 0.5f)
) {
if (spotlightPosition.isSpecified && spotlightSize.isSpecified) {
Box(
modifier
+ .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawBehind {
drawRect(scrimColor)
drawRoundRect(
Color.Black,
spotlightPosition,
spotlightSize,
spotlightCornerRadius,
blendMode = BlendMode.DstOut
)
}
)
}
}
終わりに
それだけでした。
知っていれば何も難しくないんですが、なかなかこんなこと知らないですよね。
冒頭のアニメーションのソースコードを置いておきます。
ほなまた
@Immutable
private class Item(
val icon: ImageVector,
val text: String
)
class MainActivity : ComponentActivity() {
private val items = persistentListOf(
Item(Icons.Default.Edit, "見なくていいと書いているのに"),
Item(Icons.Default.Build, "見てしまうその心"),
Item(Icons.Default.AccountCircle, "嫌いじゃない"),
Item(Icons.Default.Phone, "今日は髪を切りました"),
Item(Icons.Default.Check, "ここは見なくていいですよ"),
Item(Icons.Default.Home, "来週は脱毛します"),
Item(Icons.Default.DateRange, "ここは注目しなくていい"),
Item(Icons.Default.Favorite, "ここに注目"),
Item(Icons.Default.Email, "ここは関係ない"),
Item(Icons.Default.Search, "左下です"),
Item(Icons.Default.Notifications, "見なくていいですよ"),
Item(Icons.Default.Settings, "隅々まで見てますね"),
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme {
Box(Modifier.background(MaterialTheme.colorScheme.background)) {
var spotlightItemIndex by remember { mutableIntStateOf(-1) }
val contentPaddingInsets = WindowInsets.safeDrawing
.add(WindowInsets(8.dp, 8.dp, 8.dp, 8.dp))
val lazyGridState = rememberLazyGridState()
LazyVerticalGrid(
columns = GridCells.Adaptive(100.dp),
state = lazyGridState,
contentPadding = contentPaddingInsets.asPaddingValues(),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize()
) {
itemsIndexed(items) { index, item ->
Item(
item = item,
onClick = {
spotlightItemIndex =
if (spotlightItemIndex == index) { -1 } else { index }
},
modifier = Modifier.fillMaxSize()
)
}
}
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val contentPaddingOffset = IntOffset(
contentPaddingInsets.getLeft(density, layoutDirection),
contentPaddingInsets.getTop(density)
)
val spotlightRect by remember(density, contentPaddingOffset) {
derivedStateOf {
val layoutInfo = lazyGridState.layoutInfo
val spotlightItemInfo = layoutInfo.visibleItemsInfo
.singleOrNull { it.index == spotlightItemIndex }
?: return@derivedStateOf null
Rect(
spotlightItemInfo.offset.toOffset(),
spotlightItemInfo.size.toSize()
)
.translate(contentPaddingOffset.toOffset())
.inflate(with(density) { 8.dp.toPx() })
}
}
SpotlightScrim(
spotlightPosition = spotlightRect?.topLeft ?: Offset.Unspecified,
spotlightSize = spotlightRect?.size ?: Size.Unspecified,
spotlightCornerRadius = 8.dp,
modifier = Modifier.fillMaxSize()
)
}
}
}
}
}
@Composable
private fun Item(
item: Item,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
@OptIn(ExperimentalMaterial3Api::class)
Card(
onClick = onClick,
modifier = modifier
) {
Column(Modifier.padding(8.dp)) {
Icon(
imageVector = item.icon,
contentDescription = item.text,
modifier = Modifier.padding(4.dp)
)
Text(
item.text,
modifier = Modifier.padding(4.dp)
)
}
}
}
@Composable
fun SpotlightScrim(
spotlightPosition: Offset,
spotlightSize: Size,
modifier: Modifier = Modifier,
scrimColor: Color = Color.Black.copy(alpha = 0.5f),
spotlightCornerRadius: Dp = 0.dp,
) {
var scrimSize by remember { mutableStateOf(Size.Unspecified) }
val scrimModifier = if (!scrimSize.isSpecified) {
Modifier.onSizeChanged { scrimSize = it.toSize() }
} else {
val animatedPosition by animateOffsetAsState(
if (spotlightPosition.isSpecified) { spotlightPosition } else { Offset.Zero },
label = "spotlight position"
)
val animatedSize by animateSizeAsState(
if (spotlightSize.isSpecified) { spotlightSize } else { scrimSize },
label = "spotlight size"
)
Modifier.drawBehind {
scrimSize = size
if (animatedSize.isSpecified) {
drawRect(scrimColor)
drawRoundRect(
Color.Black,
animatedPosition,
animatedSize,
CornerRadius(spotlightCornerRadius.toPx(), spotlightCornerRadius.toPx()),
blendMode = BlendMode.DstOut
)
}
}
}
Box(
modifier
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.then(scrimModifier)
)
}