Video Player in Jetpack Compose (Part 3) — Controls and Fade Animation

Controls overlay with a simple Play/Pause button

Part 1 ; Part 2

So far in this series we’ve achieved to play a video in a Composable. Through component dependencies, the video player also supported changing the source. Finally, the video player also returned media controls interface which allowed us to implement our own control buttons outside the player view. We are going to focus on adding a more sophisticated control unit in this article. Initially the plan was to also go over implementation of gestures in this article but it had changed to keep the article short and concise.

Basic Control Buttons

First of all, we want to include a play/pause button at the center of the video, just like popular video players from Youtube or Netflix. Of course this button will appear and disappear with a tap to video surface. To achieve this kind of layout, most basic component that we can use is Stack . This component lays out its children in Z axis. Contrary to row and column, it does not work on X-Y axes. We will use Stack to show a surface of control buttons(just play/pause for now).

Before we wrap video player surface with a Stack , we’d better start separating components with respect to their logical parts. ExoPlayer should be decoupled from UI, so we create a PlayerController which will also adopt MediaPlaybackresponsibilities. Since the controller is going to be used by many child components, we should provide it down the tree by Ambient API.

val PlayerControllerAmbient = ambientOf<PlayerController> { 
("PlayerController is not provided for this scope.")
PlayerControllerAmbient provides playerController
) {
The most basic PlayerController

Let’s move AndroidView that hosts PlayerView into its own component called PlayerSurface.

Now we are ready to use Stack to draw an overlay which will hold media control buttons.

PlayerControllerAmbient provides playerController
) {
val controlsVisible by playerController.controlsVisible.collectAsState()
Stack() {
if(controlsVisible) {
MediaControlButtons(modifier = Modifier.matchParentSize())

We will get to MediaControlButtons in a minute but we first have to talk about how stack works and what is matchParentSize() modifier? As it is discussed earlier, Stack provides a way of putting components on top of each other in Z axis. However, horizontal and vertical layout of children of stack is configured through modifiers that exist under StackScope. These modifiers are matchParentSize and gravity .

  • gravity: Pull the content element to a specific [Alignment] within the [Stack].
  • matchParentSize: Size the element to match the size of the [Stack] after all other content elements have been measured. The element using this modifier does not take part in defining the size of the [Stack]

I’d recommend checking out this page from Jetpack Compose Playground docs to understand how gravity modifier works. matchParentSize is not magical either. It tells the component that it is applied to: “match the size of this stack component”. Using this modifier, we will match MediaControlButtons‘s height and width to the that of PlayerSurface.

Below examples show how exactly gravity and matchParentSize work.

Let’s start adding control buttons by a simple Play/Pause toggle button. MediaControlButtons should also have a transparent dark background to present contrast between video and the overlay content.

We are again making use of stack. This time we are putting a background box to catch clicks. If the user clicks on the background of dark overlay, controls should disappear from the screen. Also, we have a separate UI component for PlayPauseButton which will be centered on controls surface.

However, our project currently does not have extended icon set from Compose. Add the following dependency to your build.gradle to have access to vast majority of Material Icons.

implementation "androidx.compose.material:material-icons-extended:$compose_version"

There are use of non-existent functionalities in several lines. Currently, our player does not expose a state of isPlaying or playbackState . Neither it provides a playPauseToggle . Going back to the controller, a simple listener should do the trick.

The visibility of MediaControlButtons must be part of the state. Introducing a boolean state in PlayerController with setter function should be enough.

private val _controlsVisible = MutableStateFlow(false)
val controlsVisible: StateFlow<Boolean> = _controlsVisible

fun setControlsVisible(isVisible: Boolean) {
_controlsVisible.value = isVisible

Finally, we’ll need a clickable to show the controls when they are gone from the UI. Although we can add a clickable modifier to either root Stack or PlayerSurface , creating a separate surface that catches clicks will serve better in the future when we add gestures like dragging or double tapping.

fun SurfaceGestures(modifier: Modifier = Modifier) {
val playerController = PlayerControllerAmbient.current

modifier = modifier
.clickable(indication = null) {

Add it to the root stack

SurfaceGestures(modifier = Modifier.matchParentSize())
if(controlsVisible) {

There we have it. It took a bit of time and multiple Composable components. In the end the cornerstones were “utilizing Stack“ and “decoupling player from the UI hierarchy”.

Animate Toggle

For now, the toggle looks and feels raw. The answer is simple; fade animation. Our experience from all the other interactions lead us to expect some kind of transition indicator before showing or hiding the controls. Although for any fade animation, I would recommend using CrossFade composable, it does not quite work in here because this not a switch between two existing layouts.

The above code is taken from a complete project so there are composables that we haven’t yet implemented. Content composable is very similar to what we currently have. The animation happens in invoke operator, which also makes this object callable and composable.

Fade animations for switching between visible and invisible states have a common pitfall; whether to remove the view from UI, if yes, when?

Let’s simplify the situation with the following;

  • Invisible: Does not exist on UI tree
  • Visible: Exists on UI tree with any opacity level
Invisible -> Visible(alpha=0.0) -> Visible(alpha=0.1) -> ...
Visible(alpha=0.9) -> Visible(alpha=1) ~ Visible
Disappear: Reversed Appear

To remove a view from the tree, we must wait until the animation is complete. So we create another state for “existing”. This state depends on the actual state for visibility that is coming from the controller. When controlsVisible change to whether true or false, “existing” becomes true because we always have an animation for both appear and disappear actions.

Also, when the animation finishes, we check whether the view became visible or invisible. If it is now visible, we don’t have to do anything but if it became invisible, we have to remove it from the hierarchy. Simply add an animation listener which modifies the “existing” state.

onStateChangeFinished = {
setControlsExistOnUITree(it == VISIBLE)

I hope this article helped you better understand UI layout and animations in Compose.



Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store