As of writing this article, Jetpack Compose is in beta! Yey! However, it is not totally complete. There is not (AFAIK) yet an official view pager library from the Compose team. But, there are some options.

In Sats we are trying out Jetpack Compose and was faced with the issue of implementing a view pager in our app. Together with the team we wanted to implement sort of like a news flashing card in the top of the app, showing your three first upcoming workout sessions. The next vertical card was going to show the three closest clubs if the user had granted us the permission to track their location. This implied having a lot of different states that the cards could be in, as you could have zero, one, two or three upcoming sessions. In addition to that we also had to implement the possibility for having a upcoming PT session in the mix. The nearby clubs card could also have a bunch of different states as shown in the design illustration below.

Design variations

So, IMO this was a perfect candidate for Jetpack Compose since then we did not have to deal with writing a hole bunch of XML markup for each state and so fourth. So I thought; this is going to be a lot of @Composable fun! And I was right!

Firstly we started looking into the Jetcaster app, where they have implemented a view pager in Compose. We used this article by Jorge Castillo, which guides you through every part of the implementation. However, we noticed some things we did not like with this implementation. If you try it out, you will find it a bit strange to use because of issues with the fling and touch handling. So if you want to become the next Compose rockstar, you should definitely start writing a pager library for Jetpack Compose! But wait …

So what are the options?

Right now, Chris Banes is figuring out something by using the Jetcaster solution and making it more pleasant for the user to use. You can follow this PR for more information. Meanwhile, a solution is to use Compose Android view interop. Then we would still take advantage of all the powerful features in the viewpager2 library, but also be able to use all the mighty features of Jetpack Compose!

Firstly, what do I mean about Compose Android veiw interop?

Compose Android view interop, is essentially the way for us to combine Android XML and composable functions. There are a couple of ways to do this. You can either inject an Android view into a composable (you can read more about that here) or you can inject a composable into an Android view. So in Sats, in order for us to use viewpager2, we have to go with the seccond approach.

But how?

Firstly, we started by making a custom Android view and injecting it into your XML code like this:

class TopStoryFrameLayout : FrameLayout {
    constructor(context: Context) : super(context)

    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)

    constructor(context: Context, attrs: AttributeSet?, @AttrRes defStyleAttrs: Int) : super(
        context,
        attrs,
        defStyleAttrs
    )
}
<TopStoryFrameLayout
    android:id="@+id/top_story_frame_layout"
    android:layout_width="0dp"
    android:layout_height="wrap_content" />
@AndroidEntryPoint
class DiscoverFragment : Fragment(R.layout.fragment_discover) {

    private val viewBinding by viewBinding(FragmentDiscoverBinding::bind)

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        viewBinding.topStoryFrameLayout.removeAllViews()
        val topStoryComposeView = TopStoryFrameLayout(view.context)

        viewBinding.TopStoryFrameLayout.addView(topStoryComposeView)
    }

}

(Our discover fragment is the first fragment the user sees in our app and is displayed with our new “hot” information about Sats and the app.)

Furthermore, in our brand new custom Framelayout, we inflated our view pager layout. Then we set our adapter, orientation and offscreenPageLimit. The offScreenPagelimit is how many pages that will be kept offscreen on either side of the pager.

init {
    inflate(context, R.layout.top_story_view_pager_layout, this)

    viewpager2 = findViewById<ViewPager2>(R.id.top_story_view_pager_layout).apply {
        adapter = topStoryAdapter
        orientation = ViewPager2.ORIENTATION_HORIZONTAL
        offscreenPageLimit = 2
    }
}

Since we are going to work with Compose, it makes sense to use unidirectional data flow. You can read more about that here if you are not familiar with this type of achitecture.

Further, we needed a view model to hold our states and fetch our data.

class TopStoryViewModel constructor(
    private val topStoryRepository: TopStoryRepository,
) : ViewModel() {

    val topStoryState: StateFlow<TopStoryState> get() = _topStoryViewState
    private val _topStoryViewState = MutableStateFlow(TopStoryState.Loading)

    fun fetchTopStory(position: Position?) {
        viewModelScope.launch {
            topStory = Lce.Loading
            updateViewState()

            topStory = when (val result = topStoryRepository.fetchTopStoryData(position = position)) {
                is Result.Success -> {
                    _topStoryState.emit(TopStoryState.Content(topStory = result.value))
                }
                is Result.Failure -> {
                    _topStoryState.emit(TopStoryState.Error)
                }
            }
        }
    } 
}

sealed class TopStoryState {
    object Loading : TopStoryState()
    object Error : TopStoryState()
    data class Content(val topStory: TopStory) : TopStoryState()
}

As you can se here, the position object is nullable here. This will be null if the user has not granted permission in the fragment to use their location (or the location is null for some reason).

We then added an observer on that StateFlow in our fragment.

private val topStoryComposeViewModel by viewModels<TopStoryViewModel>()

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

        // ...

    topStoryComposeViewModel.topStoryState
      .onEach { topStoryComposeView.setViewState(it) }
      .launchIn(lifecycleScope)
}

As you can se from the lambda above, we are calling on the setViewState function in our custom composable. Since the StateFlow in the view model is initialized with TopStoryViewState(Lce.Loading), it will start in the loading state. In the custom frame layout, we will set the state and update our viewpager2 adapter according to our state.

fun setViewState(viewState: TopStoryState) {

    when (val topStory = viewState.topStory) {
        is Loading -> {
             topStoryAdapter.updateTopStoryState(TopStoryState.Loading)
        }
        is Content -> {
            topStoryAdapter.updateTopStoryState(
                TopStoryState.Content(
                    topStory.content.data
                )
            )

            findViewById<ComposeView>(R.id.switching_dots_tab_layout).apply {
                setContent {
                    SatsTheme {
                        PageIndicator(
                            modifier = Modifier.padding(top = 16.dp),
                            currentPage = currentPage.collectAsState().value,
                            numberOfPages = topStory.content.data.count()
                        )
                    }
                }
            }
        }
        is Error -> {
            topStoryAdapter.updateTopStoryState(TopStoryState.Error)
        }
    }
    topStoryAdapter.notifyDataSetChanged()
}

As a sidenote, you can see that we can easily use Compose in here as we did with the page indicator. We can use a Compose xml view and override the set content method with our composable function.

@Composable
private fun PageIndicator(modifier: Modifier = Modifier, currentPage: Int, numberOfPages: Int) {
    Row(modifier = modifier) {
        repeat(times = numberOfPages) { page ->
            val circleColor = if (page == currentPage) {
                SatsTheme.colors.onBackgroundPrimary
            } else {
                SatsTheme.colors.onBackgroundDisabled
            }
            Box(
                modifier = Modifier
                    .padding(end = 8.dp)
                    .clip(shape = CircleShape)
                    .background(color = circleColor)
                    .size(8.dp)
            )
        }
    }
}

The viewpager2 adapter

class TopStoryAdapter : RecyclerView.Adapter<TopStoryViewHolder>() {

    companion object {
        const val UPCOMING_TRAINING_VIEW = 0
        const val CLUB_NEARBY_VIEW = 1
        const val LOADING_VIEW = 2
        const val ERROR_VIEW = 3
    }

    private var topStoryState: TopStoryState = TopStoryState.Loading

    private var callbacks: TopStoryCallbacks? = null

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TopStoryViewHolder {
        val layoutInflater = LayoutInflater.from(parent.context)
        val inflated = layoutInflater.inflate(R.layout.top_story_compose_view, parent, false)
        val composeView = inflated.findViewById<ComposeView>(R.id.top_story_compose_view)

        return when (viewType) {
            UPCOMING_TRAINING_VIEW -> {
                UpcomingTrainingViewHolder(composeView, callbacks = callbacks)
            }
            CLUB_NEARBY_VIEW -> {
                ClubsNearbyViewHolder(composeView, callbacks = callbacks)
            }
            LOADING_VIEW -> {
                LoadingViewHolder(composeView)
            }
            ERROR_VIEW -> {
                ErrorViewHolder(composeView, callbacks = callbacks)
            }
            else -> error("Invalid viewType")
        }
    }

    override fun onBindViewHolder(holder: TopStoryViewHolder, position: Int) {
        when (holder) {
            is UpcomingTrainingViewHolder -> {
                val state = topStoryState as TopStoryState.Content
                val data = state.data[position] as Data.UpcomingTraining
                holder.bind(data)
            }
            is ClubsNearbyViewHolder -> {
                val state = topStoryState as TopStoryState.Content
                val data = state.data[position] as Data.ClubsNearby
                holder.bind(data)
            }
            is LoadingViewHolder -> {
                holder.bind()
            }
            is ErrorViewHolder -> {
                holder.bind()
            }
        }
    }

    fun updateTopStoryState(topStoryState: TopStoryState) {
        this.topStoryState = topStoryState
        notifyDataSetChanged()
    }

    fun setCallbacks(callbacks: TopStoryCallbacks?) {
        this.callbacks = callbacks
    }

    override fun getItemViewType(position: Int): Int {

        return when (val state = topStoryState) {
            is TopStoryState.Loading -> LOADING_VIEW
            is TopStoryState.Error -> ERROR_VIEW
            is TopStoryState.Content -> when (state.data[position]) {
                is Data.UpcomingTraining -> {
                    UPCOMING_TRAINING_VIEW
                }
                is Data.ClubsNearby -> {
                    CLUB_NEARBY_VIEW
                }
                else -> {
                    error("Invalid data type: ${state.data[position]::class.simpleName}")
                }
            }
        }
    }

    override fun getItemCount(): Int {
        return when (val state = topStoryState) {
            is TopStoryState.Loading -> 1
            is TopStoryState.Error -> 1
            is TopStoryState.Content -> state.data.count()
        }
    }
}

Our view holders

sealed class TopStoryViewHolder(view: ComposeView) : RecyclerView.ViewHolder(view)

class UpcomingTrainingViewHolder(private val view: ComposeView, private var callbacks: TopStoryCallbacks? = null) :
    TopStoryViewHolder(view) {
    fun bind(data: Data.UpcomingTraining) {
        view.setContent {
            SatsTheme {
                UpcomingTraining(
                    modifier = Modifier
                        .padding(start = 8.dp, end = 8.dp, bottom = 2.dp)
                        .wrapContentHeight(align = Alignment.Top, unbounded = true),
                    events = data.content.events,
                    onDataClicked = { callbacks?.upcomingDataInteraction() },
            }
        }
    }
}

class ClubsNearbyViewHolder(private val view: ComposeView, private var callbacks: TopStoryCallbacks? = null) :
    TopStoryViewHolder(view) {
    fun bind(data: Data.ClubsNearby) {
        view.setContent {
            SatsTheme {
                NearbyClubsCard(
                    modifier = Modifier
                        .padding(start = 8.dp, end = 8.dp, bottom = 2.dp)
                        .wrapContentHeight(align = Alignment.Top, unbounded = true),
                    clubs = data.content.clubs,
                    locationIsActivated = data.content.isLocationBased,
                    onDataClicked = { callbacks?.nearbyClubsInteraction() },
                )
            }
        }
    }
}

class LoadingViewHolder(private val view: ComposeView) : TopStoryViewHolder(view) {
    fun bind() {
        view.setContent {
            SatsTheme {
                LoadingScreen(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(start = 8.dp, end = 8.dp, bottom = 48.dp)
                        .clip(shape = RoundedCornerShape(size = 4.dp))
                        .background(color = SatsTheme.colors.background.secondary)
                )
            }
        }
    }
}

class ErrorViewHolder(private val view: ComposeView, private var callbacks: TopStoryCallbacks? = null) :
    TopStoryViewHolder(view) {
    fun bind() {
        view.setContent {
            SatsTheme {
                ErrorScreen(
                    modifier = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 20.dp),
                    screenType = ScreenTypes.UpcomingTraining,
                )
            }
        }
    }
}

So, as you can see there is a lot going on in this adapter and we are going to look into it in a bit more detail. Firstly, we need to know which view holder to inflate. For that, we made a companion object that will hold our view type and return that when we know what state is being held. Then, when the view holder is being created it can inflate the proper view holder determined by the top story state. In our view holders we are pasing in a ComposeView that we can invoke the setContent(@Composable () -> Unit) function on. This is essentially making our bridge from Android view to Compose.

Furthermore, for the view holders that actually display data that can be interacted with, like retry buttons, book buttons and other data that needs us to update our view effect so that we can navigate places. They need to pass in a callback interface, so that we in our fragment can interact with that and our view model. This will handle our unidirectional data flow going up from the view.

interface TopStoryCallbacks {
    fun upcomingDataInteraction()

    fun nearbyClubsDataInteraction()
}

Conclusion

Eventhough Jetpack Compose is just in beta, IMO it works really well! There is no problem combining Android views and Compose views with interop. This makes Compose really flexible and easy to use in apps that are not already completely written in Compose. Further, if you need to use a viewpager in Jetpack Compose, IMO this is not a bad solution until the Compose team comes out with their own official version.

References: