Thinking in React for native Android apps

14 min read
A guide using MvRx, Navigation, Flexbox and some Kotlin sugar.
Post cover image

I like writing apps using React Native. In contrast, working on Java Android apps has always been less satisfying because:

  1. Boilerplate.
  2. ConstraintLayout? LinearLayout? RelativeLayout? TableLayout? GridLayout?
  3. ???

It's a simple fact that the more code you write, the more likely you are to write bugs. I knew using Kotlin would improve this aspect, but I wanted to get more experienced with Java, so that I'd better appreciate Kotlin when I made the move. I also didn't believe that just by using Kotlin, I'd suddenly really enjoy developing native apps.

Recently, I came across MvRx (Mavericks). An Android framework open-sourced by Airbnb. I learned that it is conceptually inspired by React which piqued my interest. It even brings over the familiar setState() syntax. Since MvRx is Kotlin-only, it tipped me over to start learning Kotlin.

The same todo app written using MvRx is about 1000 lines of code less than the same app written in Java.

Syntax-wise Kotlin has many similarties to TypeScript which I've always preferred using. I learned by going through the Kotlin official docs (which are awesome) and doing some of the the Kotlin Koans.

They say that no Java developer has tried Kotlin and wanted to go back to writing Java. I concur.

I previously used MVP architecture, whereas MvRx is similar to MVVM/MVI. Presenters and ViewModels harbour the logic of your screens (similar to container components in React). The main difference between them is that a ViewModel never holds a reference to any view. It simply updates its state and the view observes the data changes and much like React, re-renders accordingly. Therefore, there is no fear of referencing a null view (similar to calling setState() on an unmounted React component). This greatly simplifies dealing with view lifecycles.

Lifecycle vs ViewModel

During my process of learning MvRx and Kotlin, I came across a few helper libraries that improve development experience. I decided to learn and use them.

I slowly came to the realization that sometimes, we might not enjoy working with a framework not because of the framework itself, but simply because of the way we apply it.


Guide

We're going to make a simple app utilizing the following libraries:

  • Navigation Component
  • Kotlin Android Extensions
  • MvRx
  • Flexbox Layout

The app flow will be as such:

  1. Login Screen:
    • Two text input fields.
    • Login button.

Once the user presses the login button, we will mock a request using a simple delay. During the mock request, we will hide the view and show a loading indicator. Once the request is done, we will restore our view, hide the loading indicator and navigate to the landing screen.

  1. Landing Screen:
    • This screen will simply show the data entered in the previous text inputs and a logout button.

Simple enough.

Dependencies

Let's start by creating a blank Android Studio project with Kotlin and add our dependencies.

New android studio project

Add MvRx and the navigation component to your dependencies block, under app/build.gradle:

dependencies {
    def navVersion = "2.1.0"
    def mvrxVersion = '1.1.0'

    // Navigation component
    implementation "androidx.navigation:navigation-fragment-ktx:$navVersion"
    implementation "androidx.navigation:navigation-ui-ktx:$navVersion"

    // MvRx
    implementation "com.airbnb.android:mvrx:$mvrxVersion"

    // Flexbox
    implementation 'com.google.android:flexbox:1.1.0'
    // ...

At the top of the same file:

// Kotlin Android Extensions
apply plugin: 'kotlin-android-extensions'

Layouts

We will use single activity - multiple fragments pattern. Fragments should be designed as resuable and modular components, just like presentational components in React.

Create our layout files: Right-click the res folder then select New > Android Resource File. Set the type as Layout.

Layout files

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <fragment
        android:id="@+id/host"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>

login_fragment.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#F1F1F1">

    <com.google.android.flexbox.FlexboxLayout
        android:layout_width="match_parent"
        android:layout_height="250dp"
        android:layout_marginTop="200dp"
        app:alignItems="center"
        app:flexWrap="wrap"
        app:justifyContent="center">

        <EditText
            android:id="@+id/loginNameText"
            android:layout_width="120dp"
            android:layout_height="60dp"
            android:hint="Name"
            android:importantForAutofill="no"
            android:inputType="text"
            app:layout_flexBasisPercent="80%"
            tools:text="Name" />

        <EditText
            android:id="@+id/loginCityText"
            android:layout_width="120dp"
            android:layout_height="60dp"
            android:hint="City"
            android:importantForAutofill="no"
            android:inputType="text"
            app:layout_flexBasisPercent="80%"
            tools:text="City" />

        <Button
            android:id="@+id/loginButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="30dp"
            android:backgroundTint="#6200EE"
            android:text="LOGIN"
            android:textColor="#FFF"
            app:layout_flexBasisPercent="80%" />

        <ProgressBar
            android:id="@+id/loadingIndicator"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:visibility="gone"
            app:layout_flexBasisPercent="100%"
            />

    </com.google.android.flexbox.FlexboxLayout>


</FrameLayout>

The root is a <FrameLayout/>. The <fragment/> tag in activity_main.xml will be swapped for the contents (children) of <FrameLayout/> in each of our fragments. (a bit like React's children?)

The child of the root layout is <FlexboxLayout/>. I've highlight ed flexbox specific properties. Pretty cool. ConstraintLayout is nice if you prefer to customize the layout visually.

Feel free to use whatever you fancy.

Login fragment layout

landing_fragment.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#F1F1F1">

    <com.google.android.flexbox.FlexboxLayout
        android:layout_width="match_parent"
        android:layout_height="250dp"
        android:layout_marginTop="200dp"
        app:alignItems="center"
        app:flexWrap="wrap"
        app:justifyContent="center">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:textColor="@android:color/black"
            android:textSize="24sp"
            app:layout_flexBasisPercent="50%"
            android:text="Name:" />

        <TextView
            android:id="@+id/landingNameText"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:textColor="@android:color/black"
            android:textSize="24sp"
            app:layout_flexBasisPercent="50%"
            tools:text="placeholder" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:textColor="@android:color/black"
            android:textSize="24sp"
            app:layout_flexBasisPercent="50%"
            android:text="City:" />

        <TextView
            android:id="@+id/landingCityText"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:textColor="@android:color/black"
            android:textSize="24sp"
            app:layout_flexBasisPercent="50%"
            tools:text="placeholder" />


        <Button
            android:id="@+id/logoutButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="30dp"
            android:backgroundTint="#F05E54"
            android:text="LOGOUT"
            android:textColor="#FFF"
            app:layout_flexBasisPercent="80%" />

    </com.google.android.flexbox.FlexboxLayout>


</FrameLayout>

Landing fragment layout

Classes

Create our Kotlin classes to associate with each layout.

Classes

To create an activity, we would usually extend the AppCompatActivity class directly. But since we want to use MvRx, we will extend BaseMvRxActivity instead (which inherits from AppCompatActivity) for MvRx support. We will also override onCreate() and inflate activity_main.xml here.

Tip: here is a super useful page for looking up Android lingo like 'inflate'.

MainActivity.kt

package com.example.mymvrxapp

import android.os.Bundle
import com.airbnb.mvrx.BaseMvRxActivity


class MainActivity() : BaseMvRxActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

Every activity must be registered in the manifest. We will register MainActivity and set it as the starting activity.

AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.mymvrxapp">

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name="com.example.mymvrxapp.MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

For our fragments, we will extend BaseMvRxFragment instead of Fragment. We must also implement invalidate(). We will leave it empty for now and go over it later on.

LoginFragment.kt

package com.example.mymvrxapp

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.airbnb.mvrx.BaseMvRxFragment

class LoginFragment : BaseMvRxFragment() {
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.login_fragment, container, false)
    }

    override fun invalidate() {
    }
}

LandingFragment.kt

package com.example.mymvrxapp

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.airbnb.mvrx.BaseMvRxFragment

class LandingFragment : BaseMvRxFragment() {
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.landing_fragment, container, false);
    }

    override fun invalidate() {
    }
}

If we run the app at this point, its going to crash. Our <fragment/> in activity_main.xml needs an ID and a name to associate it with a fragment. We've given it an ID but we haven't told it what fragment its going to host yet.

Fragment name prop

We're not going to do this. Instead, we are going to associate it with our navigation graph. using the Navigation Component.

Simply put, it's a library that simplifies how we handle navigation with a neat API and a friendly interface to visualize our routes.

The navigation component fulfills the same job as React Navigation and React Router.

Fun React Navigation Fact: We can configure it to use FragmentActivity instead of plain View's under the hood for performance improvements.

Create our navigation graph. Right-click the res folder and then select New > Android Resource File. Set the type as Navigation.

nav_graph.xml

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/nav_graph" />

Now that we've created the file for our navigation graph, we will add an ID to <fragment/> and designate it as our navigation host by adding the following attributes:

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <fragment
        android:id="@+id/host"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:defaultNavHost="true"
        app:navGraph="@navigation/nav_graph" />

</LinearLayout>

Lets add our fragment classes to the navigation graph to mark them as possible destinations. I like to use the visual editor for this part.

Open nav_graph.xml in the visual editor, and add LoginFragment and LandingFragment.

Adding destinations

Select the login fragment in the graph then click the home icon to mark it as the starting destination.

Next, drag from the edge of the login fragment to the landing fragment to create a navigation action.

Route

Now your navigation graph and markup should look similar to this.

nav_graph.xml

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/nav_graph"
    app:startDestination="@id/loginFragment">
    <fragment
        android:id="@+id/landingFragment"
        android:name="com.example.mymvrxapp.LandingFragment"
        android:label="LandingFragment"
        tools:layout="@layout/landing_fragment" />
    <fragment
        android:id="@+id/loginFragment"
        android:name="com.example.mymvrxapp.LoginFragment"
        android:label="LoginFragment"
        tools:layout="@layout/login_fragment">
        <action
            android:id="@+id/action_loginFragment_to_landingFragment2"
            app:destination="@id/landingFragment" />
    </fragment>
</navigation>

tools attribute is for previewing your layouts in the navigation graph, otherwise you'd only see a plain and boring rectangle.

Nav graph complete

If we run the app now, we should see the Login screen.

Logic

Lets start by adding state to our text inputs. We need to do the following:

  • Create our data class which describes the shape of our state.
  • Create our view model class which will hold functions that trigger our state changes.

I created both the data class and the view model in the same file as MainActivity.kt for convenience but that is not a requirement.

FormState

data class FormState(
    val name: String = "",
    val city: String = "",
    val loggedIn: Async<Boolean> = Uninitialized
) : MvRxState

We must set the initial state by providing default arguments. Notice it implements MvRxState. That's required for any data class we wish to use as state.

In React, we might have a loading state and set it before and after the completion of asynchronous tasks. In MvRx, Async is a sealed class that comes with types like Loading and Success. We can simply refer to the current type of the async value to react to loading and success states. Super helpful.

FormViewModel

class FormViewModel(initialState: FormState) :
    BaseMvRxViewModel<FormState>(initialState, debugMode = BuildConfig.DEBUG) {

    init {
        logStateChanges()
    }

    fun setNameAndCity(name: String, city: String) {
        setState { copy(city = city, name = name) }
    }
    // We will go over this one in depth later on
    fun doLogIn() {
        Single.just(true)
            .delaySubscription(5, TimeUnit.SECONDS)
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .execute { copy(loggedIn = it) }
    }

    fun doLogout() {
        setState {
            copy(loggedIn = Uninitialized)
        }
    }
}

debugMode = BuildConfig.DEBUG will make some safety checks when working with the debug build. The init block and logStateChanges() are also optional. logStateChanges() does exactly what it says. We will show its output when we finish our app.

Our setState reducers will be called from our views to update the state. Similar to React, the setState block is an async operation and a pure function that takes in the current state, and returns the new state.

Notice the copy() syntax within the setState block. Inside setState, this keyword would be our data class and copy() is a method that belongs to data classes in Kotlin. It allows you to modify select properties instead of all (we don't need to spread the current state, in React lingo).

Next, we want to be able to access state from our fragments. Our login and landing fragments must subscribe to the same view model instance we defined in our main activity.

LoginFragment.kt

class LoginFragment : BaseMvRxFragment() {
    // Fetch the ViewModel scoped to the current activity or create one if it doesn't exist
    private val formViewModel: FormViewModel by activityViewModel()

    // ...

LandingFragment.kt

class LandingFragment : BaseMvRxFragment() {
    // Fetch the existing ViewModel scoped to the current activity
    private val formViewModel: FormViewModel by existingViewModel()

    // ...

Notice by activityViewModel(). It is a Kotlin delegate that lazily returns a view model scoped to the current activity. Since both of our fragments belong to the same activity, sharing state is very straightforward.

For LandingFragment.kt, we used existingViewModel() which returns the existing view model in scope of the current activity. The difference is that this function will throw an exception if no view model exists, instead of creating a new one.

MvRx lets you easily choose the scope of the view model you'd like to work with.


As soon as our view loads (React: mounts), we're going to:

  • Add a click listener to our login button.
  • When the user presses the button we will grab the user's input and update our name and city states, then call doLogIn to start the mock request/delay.
  • When the delay starts, we must hide our view and show the loading indicator.
  • When the delay ends, we must hide the loading indicator and show our view. Then, we navigate to the landing screen.

Override onViewCreated and implement the on-click listener as described:

LoginFragment.kt

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

        loginButton.setOnClickListener {

            // Update the state
            formViewModel.setNameAndCity(
                loginNameText.text.toString(),
                loginCityText.text.toString()
            )

            formViewModel.doLogIn()
        }
    }

Because of Kotlin Android Extensions, we are able to directly reference the view without calling findViewById. This is called View Binding (similar to getting a ref to a node in React) .

Note: in the future, it will be recommended to use the View Binding component which has some advantages and none of the drawbacks. It will be available in newer versions of Android Studio.

    fun doLogIn() {
        Single.just(true)
            .delaySubscription(5, TimeUnit.SECONDS)
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .execute { copy(loggedIn = it) }
    }

MvRx makes use of RxJava. Primer: RxJava is very useful in Android development as it offers a neat API for handling asynchronous logic (Like JavaScript promises, but with more capabilities).

It is important to execute long running tasks off the main thread (also called UI thread) to prevent the app from dropping frames. RxJava allows you to declaratively switch threads with ease.

To use any of RxJava's APIs, we must work with the observable type. RxJava has APIs to create observables from things.

doLogin() is called when the login button is pressed. Let's go through it in detail as Rx can be intimidating if you've never used it before:

  • Single is a type of observable that resolves to a single value, exactly like a JavaScript promise.
  • just() is used to denote that this observable resolves to just this item, in this case the item is true. So the first line is equivalent to Promise.resolve(true) in JavaScript.
// ...
.delaySubscription(5, TimeUnit.SECONDS)
  • We need to subscribe to an observable to receive a result from it. This line states that any subscription should be delayed by 5 seconds.
// ...
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
  • Here, we state that we wish to subscribe to this observable using an io thread by the help of the io scheduler and ask for value notifications to be sent to the main thread.
// ...
.execute { copy(loggedIn = it) }
  • execute is a neat helper function by MvRx that maps the current state of this process to our loggedIn async type. When the observable is executed, loggedIn type is updated to Loading. When it is done, loggedIn type and value are updated to Success and true.

Now, the invalidate() function comes in handy. This function is called every time our state is updated (just like a React re-render). Here, we can make changes to our view according to the current state.

LoginFragment.kt

    // ...

    override fun invalidate() {
        withState(formViewModel) { state ->
            loadingIndicator.isVisible = state.loggedIn is Loading
            loginNameText.isVisible = state.loggedIn !is Loading
            loginCityText.isVisible = state.loggedIn !is Loading
            loginButton.isVisible = state.loggedIn !is Loading

            if (state.loggedIn is Success) {
                findNavController().navigate(R.id.action_loginFragment_to_landingFragment2)
            }
        }
    }

withState allows us to access the current state of our view model. Inside, we map the loading state of loggedIn to the visibility of our loading indicator, inputs and button. If loggedIn is of type Success, then we navigate to the landing screen.

Navigation KTX helps us obtain a reference to the navigation controller here.

For the landing fragment, we need to implement invalidate() and update our texts using the current state. We will add a listener to our logout button that sets loggedIn to Uninitialized and then pops our fragment off of the navigation stack, going back to the login screen.

LandingFragment.kt

    // ...

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

        logoutButton.setOnClickListener {
            formViewModel.doLogout()
            findNavController().popBackStack()

        }
    }

    override fun invalidate() {
        withState(formViewModel) { state ->
            landingCityText.text = state.city
            landingNameText.text = state.name
        }
    }

Mymvrxapp gif

Courtesy of logStateChanges():

D/FormViewModel: New State: FormState(name=Osama, city=Cyberjaya, loggedIn=com.airbnb.mvrx.Uninitialized@24591c4)
D/FormViewModel: New State: FormState(name=Osama, city=Cyberjaya, loggedIn=com.airbnb.mvrx.Loading@7749791c)
D/FormViewModel: New State: FormState(name=Osama, city=Cyberjaya, loggedIn=Success(value=true))

All done! Hope you've enjoyed this guide and found it useful.

If you'd like to learn more about MvRx, I suggest going through their wiki and code samples on their repo.

Discuss on Bluesky