Video Player in Jetpack Compose (Part 3) — Controls and Fade Animation
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 MediaPlayback
responsibilities. 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> {
error("PlayerController is not provided for this scope.")
}...Providers(
PlayerControllerAmbient provides 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.
Providers(
PlayerControllerAmbient provides playerController
) {
val controlsVisible by playerController.controlsVisible.collectAsState()
Stack() {
PlayerSurface()
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.
@Composable
fun SurfaceGestures(modifier: Modifier = Modifier) {
val playerController = PlayerControllerAmbient.current
Box(
modifier = modifier
.clickable(indication = null) {
playerController.setControlsVisible(true)
}
)
}
Add it to the root stack
...
PlayerSurface()
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
Appear:
Invisible -> Visible(alpha=0.0) -> Visible(alpha=0.1) -> ...
Visible(alpha=0.9) -> Visible(alpha=1) ~ VisibleDisappear: 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.