LoginSignup
2
1

穴開きのComposableを作る

Posted at

Composableに穴を開けたいときがありますよね。
あんまりないと思いますけど

例えばよくありそうなのはこういうやつ
spotlight.gif

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)
   )
}
2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1