Android added Picture-in-Picture support in Oreo and it’s a really great tool for launching an Activity in picture-in-picture mode. But you may not need or want to launch an entire new activity to show a video in picture-in-picture mode. Here, we’ll use the new MotionLayout that is part of ConstraintLayout 2.0 to allow a user to arbitrarily swap a video between inline, picture-in-picture, and fullscreen while remaining in the same activity. Let’s dive in!
Download the source code for this article.
Create the Resources
We’ll start by setting up our layout and a motion scene file. If you haven’t worked with MotionLayout and motion scene files before, there’s a great blog post on the Android Developer’s blog that covers everything you need to know. Defining the activity (or fragment) layout is the same as usual, just use a MotionLayout (which inherits from ConstraintLayout) as the root view.
The root view is a MotionLayout which has two relevant properties in addition to the normal ConstraintLayout properties. The app:layoutDescription property contains a reference to the “motion scene” file which goes with this MotionLayout, and the app:currentState property contains a reference to a specific ConstraintSet in the motion scene file that is used as the initial state of the MotionLayout view. The video view that we’ll be using is defined here as well, using a ControlledVideoView. What is a ControlledVideoView? It’s a custom view that I borrowed from the Google Picture-in-Picuture Sample App. It’s basically a copy of the framework’s VideoView that’s modified to include overlay video controls, including buttons to toggle Picture-In-Picure (PIP) mode, as well as Fullscreen (landscape) mode, and a close button.
Now let’s create the create the Motion Scene file that the MotionLayout references, in the res/xml folder. A Motion Scene usually consists of several ConstraintSet nodes that define constraints for the various views in the MotionLayout. Each ConstraintSet represents a single layout state that the MotionLayout view might be in, and contains Constraint nodes for each view in the layout that might change. MotionLayout provides several means for transitioning from one state to another.
Note: Due to a bug with setting visibility in motion scene files in the latest alpha version of ConstraintLayuout, this example uses the ConstraintLayout 2.0-alpha2. In alpha2, we have to specify every property on every (affected) view when defining ConstraintSets. Starting with alpha3, you can create Constraints that include only the properties that need to change, which will make motion scene files like this one much smaller and easier to mange.
The motion scene file defines 5 sets of constraints, one for each possible state that our layout can be in: inline video stopped, inline video playing, PIP video stopped, PIP video playing, and fullscreen (landscape) video playing. We don’t define a state for fullscreen video stopped, because as soon as the video is stopped while watching in fullscreen mode, we will exit fullscreen mode. So we don’t need a fullscreen stopped state. Mostly, the properties that change from state to state are the android:visibility on the video view and the constraints on the video view and other views to achieve the layout wanted for that state. Everything else stays the same for each view in each constraint set.
Typically, the motion scene file will contain one or more Transition elements that define a transition from one state (ConstraintSet) to another. However, in this case we have 5 different ConstraintSets, and at runtime we’ll need to transition from any one to any other. That would be 20 different transitions to define. Instead, we’ll use the transitionToState() method that MotionLayout provides to transition the scene from whatever state it’s in to a new state so we don’t need to define all those Transitions in our motion scene file.
Setup the ViewModel
Now that the activity and motion scene are setup, it’s time to wire them up. Let’s create a ViewModel for the activity that will have two main jobs. First, it will define two observables exposed as LiveData: PlaybackState and LayoutState. PlaybackState tells an observing view if the video should be playing or stopped. Our view will show the video player when the PlaybackState is PLAYING, and hide it when the state is STOPPED. LayoutState tells an observing view which “mode” the video should be in: inline, PIP, or full screen (landscape). Our view will use a combination of PlaybackState and LayoutState to choose one of the 5 ConstraintSets from the motion scene file, and transition the MotionLayout to that state. The ViewModel’s second job is to expose a set of methods (that I will refer to collectively as an API) that a view can call to effect changes in either PlaybackState or LayoutState. Our view will call these API methods when the user interacts with the activity, for example by pressing the play/stop button in the Options Menu, or pressing one of the controls on the video player itself. These methods will trigger updates the PlaybackState and/or LayoutState observables. The view will observe them, and react by triggering the MotionLayout to transition to a new state. We’ll start with observables and a few other properties that the ViewModel uses.
Our video player will need a URL to play, so we setup a property for that. Then we create a couple enums that define the different Playback and Layout states that we’ll need. The ViewModel also keeps track of a showInPipMode flag, which will help to determine the correct LayoutState to return to when exiting fullscreen mode, based on which state the video was in when it entered fullscreen mode. Finally we create our PlaybackState and LayoutState LiveData properties. The LayoutState LiveData uses a map to set the showInPipMode flag. Now we can fill out the rest of the ViewModel with the API for handling the video. The PlaybackState and LayoutState observables will tell our view when to play or stop the video as well as when to trigger the MotionLayout to transition the video to a different layout state.
When the video is toggled, or explicitly turned off, we update the PlaybackState. When PIP is toggled or orientation is forced, we update the LayoutState. In part 2, we’ll set the orientation based on the physical device rotation, so we extract the orientation logic out into a separate function that we can use for forced orientation as well as device orientation changes.
Setup the View
Now that the ViewModel is set, we need to setup the View (the activity or fragment hosting the video player) to call the ViewModel API methods when the user interacts with it. Again, the API methods will trigger updates to the PlaybackState and LayoutState Observables. The View will observe them, and transition the MotionLayout when they emit new values. There are several things to do in the view, so I’ll try to break it up into manageable chunks. First, we’ll need to handle clicks on overlay controls on the video, so let’s set that up. ControlledVideoView provides a Listener Interface that we can implement to handle those overlay clicks.
The listener methods are pretty straight forward. When one of the overlay buttons is clicked, our callback will forward them on to the ViewModel. Later, we’ll attache the listener on the video view when playback starts and remove it when playback stops.
The project has a menu resource with Play and Stop items. In the Activity, we can wire it up to toggle the video on and off.
Here, we’ve established a few properties to keep track of the current PlaybackState and LayoutState. When we setup observers to watch our ViewModel LiveData objects we’ll make sure these are updated. The options menu is setup to show either the play or stop button, depending on the PlaybackState. When either is clicked, we call the ViewModel’s onVideoToggled() method to trigger a change in PlaybackState.
Now comes the meat of the View setup. We need to observe the two LiveData objects from the ViewModel, and take action when they emit new values.
To keep the OnCreate callback clean, we’ve extracted the video setup work out to it’s own method. In setupVideo() first we set the url on the video player. Then we setup the PlaybackState observer. If the PlaybackState has changed, invalidate the options menu to toggle the icon displayed there, and then start or stop playback. We also need to do the all important job of transitioning the MotionLayout to a new state. That is done in updateVideoLayout(), so we call that as well. Next up, we setup the observer for LayoutState, and also call udpateVideoLayout() when the LayoutState changes.
updateVideoLayout() is where the real Motion Layout magic happens. First, it checks the current LayoutState to see if it it should be in Fullscreen mode. If it needs to change the orientation, it sets the requestedOrientation on the Activity to trigger a configuration change (more on that below). Then, using the current playback and layout state, an appropriate ConstraintSet (defined in the motion scene file) is selected. Finally, the new constraint set is passed to the MotionLayout’s transitionToState() method. And with that the entire view is transitioned to the new set of constraints, and the video moves from it’s current “mode” to the new “mode”!
We also override the onPause and onStart methods. In onPause we pause the video if it is playing. In this example, I didn’t want to blast the user with sound when returning from pause, so there is no onResume implementation to restart the video. You can add it in your apps if you like. When the Activity is stopped (say if the user backgroundss the app), things get a little tricky. Video is displayed using a Surface, and when the Activity is stopped, the Surface is destroyed. It gets re-created when the Activity is started again, but sometimes the underlying MediaPlayer still has a problem attaching to the re-created surface. our ControlledVideoVideo contains a method we can use to make sure the video will play as expected, ensureSurface. In onStart if the video was previously playing, we call this method. In addition, we tell the video player to show it’s controls so the Play/Pause button will appear for the user to click if they like. Finally, we (re)attach our Video Listener so clicks on the video controls will be handled properly.
There is some extra work to do when entering/exiting fullscreen mode. When updateVideoLayout() sets the requestedOrientation on the Activity, it triggers a call to the Activity’s onConfigurationChanged method. So let’s override that method to do a few things that need to happen when we enter/exit fullscreen (landscape) mode.
When we request a landscape orientation, we want the video to be fullscreen. So, we need to turn off the App Bar, status bar, etc. And when the orientation is NOT landscape, we need to turn it all back on. That’s what we’ve got going on here.
At this point, the video will transition between inline, PIP, and fullscreen modes, but there’s a problem with fullscreen mode. When the user toggles fullscreen mode, the orientation changes to landscape, but the framework detects that the physical orientation of the device is portrait, so it switches back to portrait. The activity is recreated but the ViewModel still knows that the LayoutState is Fullscreen mode, and so the LayoutState observer switches the Activity to landscape again. We’ve got an infinite loop. In addition, since the Activity is recreated when there is a Configuration change, the video is re-created as well, causing an undesirable stop/start of playback.
To fix these problems, update the manifest to tell the framework that the activity will handle orientation changes itself. This will stop the Activity from being recreated when the orientation changes, so there will be no infinite loop of configuration changes, and no stop/start of playback. This isn’t something you really want to do without a good reason, and in this case we do have a good reason. We want the video playback to continue through an orientation change, and for that to happen we need to keep the Activity alive.
<activity android:name=".ui.MainActivity"
android:screenOrientation="portrait"
android:configChanges="orientation|screenSize">
And that’s it! Our users can now start and stop the video from playing, toggle it into and out of Picture-in-Picture, as well as making it fullscreen in landscape mode if they like. In the next few posts, I’ll cover handling device rotation to automatically toggle fullscreen landscape mode when the device is rotated, and how to make the Picture-in-Picture video draggable so the user an move it around on the screen. Dragging is useful because other content may be covered up by the pip video view.