Hello Jetpack Compose developers!
Today we will be discussing the introduction of two new Jetpack Compose samples for Surface Duo. The first one is a video-streaming-chat-like app, and the second is a calculator app.
We will also be going over some of the dos and don’ts when creating these types of apps, as well as dive into the helpfulness of using side effects in Jetpack Compose.
Video+Chat
About a year ago we released our own Video+Chat sample written using Views, and about 3 months ago we released another app with similar tech called the FoldingVideoPlusTrivia. This specific example, as you would have guessed, is based on the former, ported over to Jetpack Compose.
Figure 1. Video+Chat single-portrait
Figure 2. Video+Chat dual-portrait
Although this app and its predecessor appear similar, their implementation is a lot more different than it would seem.
Each app makes use of the Dual View Companion Pane pattern, as well as Exoplayer for its video. How you implement Exoplayer into your app will make or break the experience.
Disclaimer: failing to follow these dos and don’ts may lead to the unfortunate passing of keyboards, mice, and even monitors as you throw your head wildly into objects out of frustration. You have been warned…
My “Exoplayer in Jetpack Compose” Experience
One of the most important parts of embedding Exoplayer into your application is to allow it to work alongside the Compose Lifecycle, rather than the Activity Lifecyle as you previously might have configured.
Because Exoplayer is embedded into a Composable as opposed to a View, attempting to follow the Activity Lifecycle rather than the Compose Lifecycle will result in adverse behaviors such as:
- Multiple sources of audio playing
- Audio and scrollbar continuing, but video is now a black screen
- Video and audio breaking completely
Yeah yeah, hindsight is 20 20. This is an obvious choice now, but one I did NOT make when I started this project. Which leads me to our dos and don’ts.
Do:
@Composable fun Video(modifier: Modifier, currentPosition: Long, updatePosition: (Long) -> Unit) { val context = LocalContext.current val mediaItem = MediaItem.fromUri("https://storage.googleapis.com/exoplayer-test-media-0/BigBuckBunny_320x180.mp4") val player = remember { ExoPlayer.Builder(context).build().apply { this.setMediaItem(mediaItem) this.playWhenReady = true this.prepare() } } DisposableEffect( key1 = AndroidView( modifier = modifier, factory = { StyledPlayerView(context).apply { this.player = player setShowPreviousButton(false) setShowNextButton(false) } }, update = { player.seekTo(currentPosition) } ) ) { onDispose { updatePosition(player.currentPosition) player.release() } } }
Don’t:
MainActivity.kt ... override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) player = ExoPlayer.Builder(this).build() setContent { MainPage(..., player = player) ... override fun onStart() { super.onStart() val mediaItem = MediaItem.fromUri("https://storage.googleapis.com/exoplayer-test-media-0/BigBuckBunny_320x180.mp4") player.setMediaItem(mediaItem) player.prepare() } companion object { const val STATE_PLAY_WHEN_READY = "playerPlayWhenReady" const val STATE_CURRENT_POSITION = "playerPlaybackPosition" } override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) player.playWhenReady = savedInstanceState.getBoolean(STATE_PLAY_WHEN_READY) player.seekTo(savedInstanceState.getLong(STATE_CURRENT_POSITION)) player.prepare() }
VideoPage.kt @Composable fun Video(modifier: Modifier, player: ExoPlayer) { val context = LocalContext.current AndroidView( modifier = modifier, factory = { StyledPlayerView(context).apply { this.player = player setShowPreviousButton(false) setShowNextButton(false) } } ) }
Because Activity lifecycles do not connect to their associated Composable lifecycles, when the Exoplayer that is embedded deep into a Composable recomposes, the callbacks that reset the Exoplayer will NOT be called and the Exoplayer will descend into the behaviors mentioned above.
So, if we don’t manage the state of the Exoplayer with normal Activity callbacks, how do we manage it?
Good question, we do that with States and Side Effects.
Namely, we save all the goodies, such as the current position of the video, into a state object. The currentPosition value that we save is the position in time of the video. We remember the position by calling remembersavable to save the value on recomposition. After that, we can then monitor and handle the changes in the composition of the Composable the Exoplayer is embedded into with DisposedEffect.
DisposedEffect allows us to set its key1 field to the AndroidView that houses our Exoplayer. Once that View changes, DisposedEffect will run whatever is in its effect field. Currently I do not have anything set to that field, but it could be used to pause the video player or set it to some other time. Nevertheless, the important thing is that when the composable is removed from the UI, we manage the release of the video player using the onDispose function. This removes any issue of multiple streams of video playing at once, but also allows for us to safely start up the video player once the composable re-enters the UI.
One of the more interesting techniques that were used here was setting the full screen. Beforehand, we would have to set the size or bounds of the composable or view to fit the entire screen, then adjust it back to its original when we exit fullscreen. How I carried out full screen HERE however was a little different. I mainly made use of the panemode in TwoPaneLayout, which is an orientation mode that sets how pane1 will span over multiple screens.
- TwoPane – Normal dual layout, pane1 content on pane1, pane2 on pane2
- VerticalSingle – When the fold is vertical (dual portrait) stretch pane1 across both panes
- HorizontalSingle – Same as vertical, but when fold is horizontal
Set your panemode
to get different orientations, or dynamically set it to allow for the screen to be fullscreen.
val paneMode = if (isFullScreen) TwoPaneMode.VerticalSingle else TwoPaneMode.TwoPane TwoPaneLayout( paneMode = paneMode, pane1 = { if (isFullScreen) { VideoPage(...) ...
Other than that, go wild. The app here, if you are unfamiliar with its View clone, depicts how one might build a video app with a chat alongside it. But given the fact that you can embed a video in this way, you can simply treat the video as a composable and toss it around your app. One idea I had originally was using more of the weight functionality in the TwoPaneLayout UI component, adding a gesture to the chat where it will modify the weights of each composable, and allow for users to swipe the chat in and out of View.
Calculator
On the other hand, the calculator is a more Jetpack Compose styling type example. The calculator functions as you would think it does. However, unlike other apps, to access the more advanced equations, the user must put the device in dual mode.
We also call our calculator sample “DyAdd,” Dyad for the duo synonym, and Add because, well, Addition.
Figure 3. DyAdd single-portrait
Figure 4. DyAdd single-landscape
As you can see, only basic functionality is available with this app when it is in single portrait mode, and even less functionality is available in single landscape.
With the shift over to dual mode, not only does the user get to access the more advanced equations, but the ticker tape also moves locations.
Figure 6. DyAdd dual-landscape
You will also notice that with the configuration change, the ticker tape goes from displaying the most recent calculation on the bottom to displaying on the top.
I accomplish this by following the Extended Canvas pattern and using TwoPaneLayout.
@Composable fun MainPage(windowState: WindowState) { TwoPaneLayout( pane1 = { BasicCalculatorPage(windowState = windowState) }, pane2 = { AdvancedCalculatorPage(windowState = windowState) } ) }
I then pass in the WindowState to each page, allowing for each page to determine which Composables to call depending on the orientation of the device. BasicCalculatorPage
and AdvancedCalculatorPage
represent the different UIs showing in each pane.
@Composable fun BasicCalculatorPage(windowState: WindowState) { if (windowState.isSinglePortrait() || windowState.isDualPortrait()) { BasicCalculatorWithHistory() } else { BasicCalculator() } } @Composable fun AdvancedCalculatorPage(windowState: WindowState) { if (windowState.isDualLandscape()) { AdvancedCalculatorWithHistory() } else { AdvancedCalculator() } }
This is a really easy way to set up your Jetpack Compose app to have dynamic and interesting ways to let users experience your app. You really can go as deep as you want with this thing, even with this example you could drill the WindowState down to each grid component and re-order how they look. I simply gave each grid a column count to change how many columns were shown.
@Composable fun AdvancedEquationGrid(columnCount: Int, modifier: Modifier = Modifier) { LazyVerticalGrid( cells = GridCells.Fixed(columnCount), ...
But you could also just pass the WindowState here and rearrange the ordering of the buttons to, say, have all the trig functions right next to each other or move all the number buttons next to or inside another grid. The sky is your limit with this stuff.
If you’re interested in checking out the source code for these projects, feel free to mosey on over here.
Happy Coding 😊
Resources and feedback
If you have any questions or would like to tell us about your dual-screen applications, use the feedback forum or message us on Twitter @surfaceduodev.
Finally, please join us every Friday on Twitch at 11am Pacific time to chat about Surface Duo developer topics!
0 comments