And Yet, it Compiles…

Picture-in-Picture Video With MotionLayout – Part 2

Handling Device Rotation

In part 1 of this series I covered how to setup an Activity or Fragment with a Video that can be toggled between inline, Picture-In-Picture, and Fullscreen landscape. Today, we’ll update the Activity to automatically switch to/from fullscreen mode when the device is rotated (or the video gets stopped while in full screen mode).

We’ve already wired up our ViewModel with a setOrientation() method that toggles fullscreen mode by setting the appropriate LayoutState value. In a nutshell, all we need to do is monitor changes to the physical orientation of the device and call this method on the ViewModel when the Activity’s orientation should be changed. But there are some obstacles to overcome along the way.

First, let’s add a new onDeviceOrientationChange() method to the ViewModel. It will take two arguments, an Int that represents the current rotation value of the device (in degrees, 0-360), and another Int that represents the current orientation of the Activity using constants like ORIENTATION_LANDSCAPE from the framework’s Configuration class. This method will eventually use these 2 values to determine if the LayoutState needs to change and if so make the call to setOrientation(). But for now let’s just stub out the method.

fun onDeviceOrientationChange(deviceOrientation: Int, activityOrientation: Int?) {
    //TODO: call setOrientation() if we need to toggle Fullscreen (landscape) mode on or off.
}

Now lets turn our attention to the Activity and detecting changes to the device’s physical orientation. Android provides an OrientationEventListener for just this purpose. Implement an OrientationEventListener and override it’s onOrientationChanged method, calling our ViewModel’s onDeviceOrientationChanged() method. The OrientationEventListener should be setup in the Activity’s OnCreate callback. However, I prefer to put it in our setupVideo() method (which we call from OnCreate) since listening for orientation changes is something we specifically want for our video. If we ever remove the video from this Activity we’ll no longer need to listen for Orientation changes.

class MainActivity : AppCompatActivity() {
    lateinit var orientationListener: OrientationEventListener

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        title = getString(R.string.app_name)

        setupVideo()
    }
    
    private fun setupVideo() {
        orientationListener = object: OrientationEventListener(this) {
            override fun onOrientationChanged(orientation: Int) {
                viewModel.onDeviceOrientationChange(orientation, resources.configuration.orientation)
            }
        }.apply { disable() }
        // ...
    }
}    

Note: Here, we disable the listener when it’s created. For this particular demo I am assuming an app that always displays in portrait mode and only allows landscape mode for viewing the fullscreen video. Because of that, we only want to listen for device orientation changes while the video is playing and ignore them while it’s not. If you want to enable landscape mode all the time, you’ll need the orientation listener to be active all the time.

Now that the listener is created and we have a handle on it, the only thing we have left to do in the Activity is enable it while video is playing and disable it while video is not playing. That way, device rotation will only cause a change in LayoutState while the video is playing. Luckily our Activity is observing changes to the current PlaybackState so that’s a great place to toggle the listener.

viewModel.playbackState.observe(this, Observer { playbackState ->
            if (playbackState != currentPlaybackState) {
                invalidateOptionsMenu() // update the play/stop icon in the options menu.
                currentPlaybackState = playbackState

                when (currentPlaybackState) {
                    MainActivityViewModel.VideoPlaybackState.PLAYING -> {
                        if (orientationListener.canDetectOrientation()) {
                            orientationListener.enable()
                        }
                        playVideo()
                    }
                    else -> {
                        orientationListener.disable()
                        stopVideo()
                    }
                }
            }

            updateVideoLayout()
        })

That’s all we need to do with the Activity. As the device is rotated (with the video playing) new rotation values will be captured and passed on to our ViewModel. Now let’s turn our attention back to the ViewModel and flesh out its onDeviceOrientationChange() method.

When the view tells the ViewModel that the device is being rotated there are a couple of hurdles to clear before the ViewModel can trigger a change to the LayoutState. First, we don’t want to respond to every minor rotation value. When you hold a phone or tablet in your hand you are constantly “rotating” the device very slightly in one direction or another, and all of these very slight changes will trigger callbacks to our OrientationEventListener with values like 0, 2, 3, 258, 257, 1, 3, etc. This also means we can’t simply watch for 0, 90, 180, and 270 rotation values. Instead, we’ll need to establish a comfortable range of rotation values, and when the device is rotated into that range we’ll toggle fullscreen mode.

Handling degrees of rotation is the easy part. Figuring out if we should toggle fullscreen mode at all is more difficult. This is because our video has a Fullscreen button that can force fullscreen mode (or forcibly exit fullscreen mode). Consider this scenario:

The user is holding the device upright (in Portrait mode) and is watching the video inline. Then he presses the fullscreen toggle button. Our app handles that and sets the LayoutState to fullscreen mode which causes the Activity to put itself into Landscape configuration. Now (because no one can hold a phone perfectly still), small orientation changes are happening with orientation values like 1, 3, 357, 2, 359.

These small changes will trigger the orientation listener to call onDeviceOrientationChange. If we just assume that these should be handled we’ll immediately put the Activity back into Portrait mode. That’s not what we want at all. When the user toggles fullscreen we want the Activity to stay there until the user wants to leave fullscreen mode, either by clicking the toggle button again or rotating back to Portrait mode from fullscreen mode. To handle this, whenever the user forces an orientation (using the fullscreen toggle button) we’ll set an orientationLock that contains the orientation mode (Portrait or Landscape) that has been forced. Then our orientation handler can check the state of the orientation lock and not do anything while lock is set. But how do we know when to release/clear the lock? That is easy. Whenever the physical device is rotated into the orientation range that matches the lock we release the lock so that future orientation changes will be handled. Continuing our use case scenario from above:

With the video playing (sideways) in fullscreen mode, an orientation lock has been set indicating landscape mode so the orientation handler will ignore rotation values. Now the user can hold his phone in his hand and the video stays sideways in Landscape mode. When the user turns his phone to landscape mode so he can watch the video our orientation handler will start receiving rotation values that are within our Landscape mode range, and since that matches the orientation lock’s value we release the lock. Now, if the user rotates the phone BACK to Portrait mode there is no lock set to prevent a change so our handler does its thing and updates the LayoutState back to inline or pip (Portrait) mode.

There is one final obstacle to overcome. If the user very quickly rotates the phone from one mode (Portrait or Landscape) to the other and back, it would be very jarring for the Activity to quickly switch from inline to fullscreen and back. To prevent this, we’ll use a Handler to debounce orientation changes a little bit in case another change comes very fast afterward.

Whew, all that sounds like alot but luckily we can accomplish everything we need to in about 30 lines of code. Let’s fill in our orientation change handler.

We’ve created a Handler to use for debouncing calls to setOrientation() and an orientation lock property. Our change handler begins by determining what the orientation should be given the current device rotation value. Next, if that orientation matches our orientation lock then we can clear the lock. Finally, if the lock is cleared and the activity’s current orientation doesn’t match our new value, then use our handler to clear out any pending calls to setOrientation() and register a new call after a brief delay. The last thing we’ve done is to update onOrientationForced() to set the orientation lock whenever the fullscreen toggle is pressed.

And that’s all there is to handling orientation. Now our users can easily switch to fullscreen mode by simply turning their device in their hands or by clicking the fullscreen toggle button on the video overlay controls. Next time, I’ll show you how to make the PIP video draggable so that users can move it around if it’s blocking content they need to see.

Responses

I will never sell or otherwise publish your email address.

You must fill in all fields.