😽

ComposeでDialogの中にVideoViewを入れるときに高さが変えられないのをどうにかする

2023/06/13に公開

初めまして、私は株式会社THIRDでAndroidエンジニアをしています。
今回は表題のJetpack ComposeでDialogを作成するときに、高さが変えられないという問題に対してのどう解決したかの技術エントリを書きました。

Jetpack ComposeでDialog(AlertDialogではない)を作成するとき、子の高さが定まっていない場合
確定次第Dialogが再コンポーズされて高さが変わるという挙動をすると思っていたのですが、
どうやら初めに測定した子要素の大きさで確定されて大きさが変わらないらしく、
stackoverflowにも同様のバグが報告されています。
VideoViewは、初め大きさが高さ幅ともに0で定義されており、onMeasure()を経てビデオの大きさをViewに反映するという順のため、Dialogが高さを決定する際にVideoViewの大きさが全く考慮されません。
VideoViewの大きさをもとにDialogの大きさを決定するという制約関係で記述するとあたかもVideoViewも何もないように表示されてしまいます。⇩videoviewがあるはず。。

VideoViewの大きさをもとにDialogの大きさを決定するという制約関係ではなく、アスペクト比を固定してやれば問題なく表示されますが、表示するビデオのアスペクト比が固定ではないときどうにかして動的な大きさのdialogを作りたいですよね?

一般的に、スクロール可能なViewを作成するとき、幅もしくは高さは固定可能なため、その固定された値を使って、videoのアスペクト比からもう一つの値(幅もしくは高さ)を求めていくというアプローチになると思います。
videoviewのコールバックを使ってアスペクト比を算出するとレイアウト後なので意味がありませんので、以下の関数を考えました。

fun getVideoAspectRatio(context: Context, videoUriString: String): Float {
    try {
        if (videoUriString.isNotEmpty()) {
            val videoUri = Uri.parse(videoUriString)
            val retriever = MediaMetadataRetriever()
            retriever.setDataSource(context, videoUri)

            val widthString =
                retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
            val heightString =
                retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)

            val width = widthString?.toFloatOrNull()
            val height = heightString?.toFloatOrNull()

            return if (width != null && height != null && width > 0) {
                height / width
            } else {
                0f
            }
        } else {
            return 0f
        }
    } catch (e: Exception) {
        e.printStackTrace()
        return 0f
    }
}

事前にアスペクト比を算出することで親レイアウト決定時にvideoviewの大きさを確定するという考え方ですが、
この方法だとローカルのビデオしか対応できず、ネットワークにあるビデオでこのメソッドを使おうとすると一度ローカルにダウンロードしてくる必要があり、かなり冗長な処理になるためこの方法は採用しませんでした。
そこで、上記のstackoverflow内にある
DialogProperties(usePlatformDefaultWidth = false)
を追記してみます。
この方法だと高さ方向に対して期待通りに再コンポーズされるものの、幅が画面いっぱいになってしまうので
元々の画面との比をデバッグで調査しました。

このspecの数値から値を分離させてやると、
usePlatformDefaultWidth = falseの時 width = 1120
usePlatformDefaultWidth = trueの時 width = 1439
(pixel 6a)
となるので、大体横幅を0.78倍くらいにしてやれば元々の幅と同じになります。(この辺りは機種依存がありそうですが大体で。)
さて、この方法だとvideo読み込み後の再コンポーズが可能なので、リスナーを使ってアスペクト比のアップデートを行うことができます。
↓こんな感じでしょうか。

AndroidView(
	modifier = Modifier.fillMaxWidth(),
	factory = { context ->
	    val videoView = VideoView(context)
	    videoView.setVideoPath(videoPath)

	    videoView.setOnPreparedListener { mediaPlayer ->
		val videoWidth = mediaPlayer.videoWidth
		val videoHeight = mediaPlayer.videoHeight
		videoAspect =
		    if (videoWidth > 0) {
			videoHeight.toFloat() / videoWidth.toFloat()
		    } else 0f
	    }
	    videoView.start()
	    videoView
	}
)

exoplayerを使う場合は以下のように書けますね。

exoPlayer.addListener(
    object : Player.Listener {
	override fun onPlaybackStateChanged(state: Int) {
	    if (state == Player.STATE_READY && exoPlayer.playWhenReady) {
		val videoWidth = exoPlayer.videoFormat?.width ?: 0
		val videoHeight = exoPlayer.videoFormat?.height ?: 0
		videoAspect =
		    if (videoWidth > 0) {
			videoHeight.toFloat() / videoWidth.toFloat()
		    } else 0f
	    }
	}
    }
)

Dialog全体は以下のように記述できます。

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun VideoDialog(
    videoPath: String,
    isShowDialog: Boolean,
    onDismiss: () -> Unit
) {
    var videoAspect by remember { mutableStateOf(0f) }
    if (isShowDialog) {
        Dialog(
            properties = DialogProperties(
                usePlatformDefaultWidth = false
            ),
            onDismissRequest = { onDismiss() },
        ) {
            Box(
                modifier = Modifier
                    .fillMaxWidth(fraction = 0.78f)
                    .verticalScroll(rememberScrollState())
            ) {
                val context = LocalContext.current
                val exoPlayer = remember(context) {
                    ExoPlayer.Builder(context).build().apply {
                        val videoSource: MediaSource = ProgressiveMediaSource
                            .Factory(DefaultDataSource.Factory(context))
                            .createMediaSource(MediaItem.fromUri(videoPath))
                        setMediaSource(videoSource)
                        prepare()
                        playWhenReady = true
                    }
                }
                exoPlayer.addListener(
                    object : Player.Listener {
                        override fun onPlaybackStateChanged(state: Int) {
                            if (state == Player.STATE_READY && exoPlayer.playWhenReady) {
                                val videoWidth = exoPlayer.videoFormat?.width ?: 0
                                val videoHeight = exoPlayer.videoFormat?.height ?: 0
                                videoAspect =
                                    if (videoWidth > 0) {
                                        videoHeight.toFloat() / videoWidth.toFloat()
                                    } else 0f
                            }
                        }
                    }
                )
                DisposableEffect(
                    AndroidView(
                        modifier = if (videoAspect > 0) {
                            Modifier
                                .fillMaxWidth()
                                .aspectRatio(videoAspect)
                        } else {
                            Modifier
                                .fillMaxWidth()
                        },
                        factory = {
                            StyledPlayerView(context).apply {
                                controllerAutoShow = false
                                player = exoPlayer
                            }
                        })
                ) {
                    onDispose { exoPlayer.release() }
                }
            }
        }
    }
}

version情報
kotlinCompilerExtensionVersion = "1.2.0-beta01"
implementation "com.google.android.exoplayer:exoplayer:2.18.7"

株式会社THIRD エンジニアブログ

Discussion