In past December, Lyft released his first Android framework, named Scoop. Scoop is a (micro) UI framework offering an alternative to systems based on either Fragments, Mortars or Flows.

To build your UI flow, Scoop offers developers these different components:

  • ViewControllers: they are in charge of managing a view and the different interactions (OnClick, OnTouch etc.) you have in them. Similar to Fragments.
  • Screens: represent a “state” of your flow. They are used to move from a controller to another. They can also store data like intents do.
  • Routers: manage your screen history. Allow user to move to a new screen, go back in the history etc.
  • Scoops: Scopes alike, used for setting up only. They also allow to bind different services to trigger everytime a new screen is about to be loaded.
  • UiContainers: in charge of updating the UI when the scoop moves to a new state by observing a router. You will have one per component in your UI: body, header/footer, popup manager etc.
  • Transitions: extra classes for customizing the animation Scoop has to use when moving from a screen to another.

This architecture might be a bit tricky at this point but I promise the journey is worth it 😃.

In this tutorial, we will use Dagger 2.0 for supporting dependency injection. If you do not know how to use this tool, I advise you to read the official documentation.

Simple application

Scaffolding

Alright pals, let’s start. Create a new Android project with an empty activity. SDK 15 is perfectly fine for this application.

Once done, remove the default Hello Word TextView from activity_main.xml. Then, open the app build.gradle and add these dependencies:

dependencies {
    compile 'com.google.dagger:dagger:2.1'
    apt 'com.google.dagger:dagger-compiler:2.1'
    compile 'com.lyft:scoop:0.3.9'
    compile 'com.jakewharton:butterknife:7.0.1'
}

Apply the apt plugin just after the android one:

apply plugin: 'com.neenbedankt.android-apt' // Should be at the top of your file

Next, open the root build.gradle and add this node into buildscript/dependencies:

// Assists in working with annotation processors for Android Studio
classpath 'com.neenbedankt.gradle.plugins:android-apt:1.4'

Our first controllers

Now, we are going to create our first views. This first iteration includes 2 screens: pistachio which has a button leading to chocolate.

Firstly, create these two layouts:

<?xml version="1.0" encoding="utf-8"?>
<!-- pistachio.xml -->
<RelativeLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#9CCC65"
    xmlns:android="http://schemas.android.com/apk/res/android"
    >

    <Button
        android:id="@+id/pistachio_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:text="Go to chocolate"
        />

</RelativeLayout>
<?xml version="1.0" encoding="utf-8"?>
<!-- chocolate.xml -->
<RelativeLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#6D4C41"
    xmlns:android="http://schemas.android.com/apk/res/android"
    >

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:text="Chocolate"
        android:textColor="#fff"
        />

</RelativeLayout>

Create a new package controllers into your workspace. Inside, create a first controller PistachioController:

public class PistachioController extends ViewController {
    @Override
    protected int layoutId() {
        return R.layout.pistachio;
    }

    @Override
    public void onAttach() {
        super.onAttach();

        ButterKnife.bind(this, getView());
    }

    @OnClick(R.id.pistachio_button)
    public void onPistachioButtonClick() {
        // TODO later
    }
}

Any controller has to extend ViewController when using Scoop. The only method you have to override is layoutId(). Here, I am also extending onAttach which is the same than onAttach for a fragment. However, the attached view (here our inflated layout) is available in this scope.

Subsequently, create a ChocolateController:

public class ChocolateController extends ViewController {
    @Override
    protected int layoutId() {
        return R.layout.chocolate;
    }
}

Now, we have to generate our screens. Attention: asserting you should have one screen per controller is wrong. A screen is not a “view” of your app but a state. You can have multiple screens for a single controller, representing its different states along your application’s lifecycle. We will talk about it later in this tutorial.

Build a first PistachioScreen into a new screens package:

@Controller(PistachioController.class)
public class PistachioScreen extends Screen {
}

Then ChocolateScreen:

@Controller(ChocolateController.class)
@EnterTransition(FadeTransition.class)
@ExitTransition(FadeTransition.class)
public class ChocolateScreen extends Screen {
}

With Scoop, any screen has to extend the Screen base class. You have to use the Controller annotation to specify what controller you are targeting. Transitions are optional.

Routing time

Great. We just designed our different views of our application. Now, we need to specify the different paths through our app:

  • When starting, the pistachio screen is displayed first
  • When clicking the embedded pistachio button, the app moves to the chocolate screen
  • Anytime, when the the back button is pressed, we should move back into the view history

That’s the job of the router. We are going to create an AppRouter in charge of handling these different paths/scenarios in our app.

Simply create a new class into a routers package:

public class AppRouter extends Router {
    public AppRouter(ScreenScooper screenScooper) {
        super(screenScooper);
    }

    @Override
    protected void onScoopChanged(RouteChange routeChange) {
        // To use later
    }
}

Fantastic. Now, we have some boilerplate work to do in order to inject automatically our AppRouter, using Dagger.

First, create a module for the routers:

// To cretae in the routers package
@Module
public class RouterModule {
    @Provides
    @Singleton
    public AppRouter provideAppRouter() {
        return new AppRouter(new ScreenScooper());
    }
}

Then, let’s initialize our main Dagger component:

// In the root package
@Singleton
@Component(modules = {
    RouterModule.class
})
public interface ApplicationComponent {
    void inject(MainActivity mainActivity);

    void inject(PistachioController pistachioController);
}

Here, I already specified the classes where our dependencies will be injected. Our Dagger graph need to be initialized now. The ApplicationComponent instance will be hold by our custom Application class (do not forget to add it to your manifest):

public class ScoopExampleApplication extends Application {
    private static ScoopExampleApplication instance;
    private        ApplicationComponent    applicationComponent;

    @Override
    public void onCreate() {
        super.onCreate();

        instance = this;

        applicationComponent = DaggerApplicationComponent
            .builder()
            .build();
    }

    public static ApplicationComponent getApplicationComponent() {
        return instance.applicationComponent;
    }
}

Nice, let’s edit our MainActivity and match the body below:

public class MainActivity extends AppCompatActivity {

    @Inject
    AppRouter appRouter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        ScoopExampleApplication.getApplicationComponent().inject(this);

        setContentView(R.layout.activity_main);
    }

    @Override
    protected void onResume() {
        super.onResume();

        // First screen to load
        if (!appRouter.hasActiveScreen()) {
            appRouter.goTo(new PistachioScreen());
        }
    }

    @Override
    public void onBackPressed() {
        if (!appRouter.goBack()) {
            // No more element in history, end the activity
            super.onBackPressed();
        }
    }
}

PistachioController as well:

public class PistachioController extends ViewController {

    @Inject
    AppRouter appRouter;

    @Override
    protected int layoutId() {
        return R.layout.pistachio;
    }

    @Override
    public void onAttach() {
        super.onAttach();

        ScoopExampleApplication.getApplicationComponent().inject(this);
        ButterKnife.bind(this, getView());
    }

    @OnClick(R.id.pistachio_button)
    public void onPistachioButtonClick() {
        appRouter.goTo(new ChocolateScreen());
    }
}

Almost there!

The final tasks are to design our custom UiContainer and setting up Scoop when starting our app (aka defining our scope).

First, let’s engineer a BodyUiContainer. It represents the body of any screen in our current scope. For instance, if we ever add a footer, we will have to build another container. A UiContainer is representing a single part of your app. You can also see them as either a sandbox or a bucket. For instance, a container can be in charge of displaying toasters/popups.

Also, as I mentioned when starting this post, this container is in charge of updating the UI when moving from a screen to another. Scoop manages this UI operation (via the goTo operation from UiContainer) for us but does not trigger it automatically. Our container has to listen to the router and then notify Scoop.

So, AppRouter has to be updated:

public class AppRouter extends Router {
    public interface ScoopChangedObserver {
        void onScoopChanged(RouteChange routeChange);
    }

    private List<ScoopChangedObserver> observers;

    public AppRouter(ScreenScooper screenScooper) {
        super(screenScooper);

        observers = new ArrayList<>();
    }

    @Override
    protected void onScoopChanged(RouteChange routeChange) {
        for (ScoopChangedObserver observer : observers) {
            observer.onScoopChanged(routeChange);
        }
    }

    public void observe(ScoopChangedObserver observer) {
        observers.add(observer);
    }

    public void stopObserving(ScoopChangedObserver observer) {
        observers.remove(observer);
    }
}

Our BodyUiContainer is pretty straightforward after that:

public class BodyUiContainer extends UiContainer {

    private AppRouter.ScoopChangedObserver observer;

    @Inject
    AppRouter appRouter;

    public BodyUiContainer(Context context, AttributeSet attrs) {
        super(context, attrs);

        ScoopExampleApplication.getApplicationComponent().inject(this);
        observer = new AppRouter.ScoopChangedObserver() {
            @Override
            public void onScoopChanged(RouteChange routeChange) {
                goTo(routeChange);
            }
        };
        appRouter.observe(observer);
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();

        appRouter.stopObserving(observer);
    }
}

Remember to add the new injection method into your root component.

Almost done! We just have to create our Scoop now. As I said before, it is a scope. See them as an extra layer of control on your UI flow. You use them for defining your scope/pool of containers. Moreover, you can attach multiple services Scoop will run everytime a new screen is about to be loaded. If you are using a Fragment system, you can see a Scoop object as the Activity in charge of attaching the different fragments.

Any Scoop needs a layout. Let’s name ours root:

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

    <com.coshx.scoopexample.components.BodyUiContainer
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        />

</RelativeLayout>

Amazing. Now we have to set it up when starting our MainActivity:

private Scoop rootScoop;

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    ScoopExampleApplication.getApplicationComponent().inject(this);

    setContentView(R.layout.activity_main);

    // Create scoop
    rootScoop = new Scoop.Builder("root").build();
    // Declare R.id.main_layout on the root layout into activity_main
    rootScoop.inflate(R.layout.root, (ViewGroup) findViewById(R.id.main_layout), true);
}

@Override
protected void onDestroy() {
    super.onDestroy();

    rootScoop.destroy(); // Good practice
}

🎉🎉🎉 Yeaaaah! We’re done. 🎉🎉🎉

Launch your app and observe how smooth your UI is.

popcorn

Tricks

Wow dude, hold on. If I rotate my phone, I have a blank screen???

Yep, you are right! To prevent this, you need to change the activity’s configuration in the manifest:

 <activity
    android:name=".MainActivity"
    android:configChanges="orientation|screenSize"
    >

If you do not want to, you can also call resetTo on your appRouter in onResume instead of goTo.


Am I supposed to call ButterKnife’s bind method in any of my controllers?

No you’re not. You can define a custom ViewBinder and set it globally for any scoop:

public class ButterKnifeViewBinder implements ViewBinder {

    @Override
    public void bind(Object object, View view) {
        ButterKnife.bind(object, view);
    }

    @Override
    public void unbind(Object object) {
        ButterKnife.unbind(object);
    }
}

Then, in your Application class:

@Override
public void onCreate() {
    super.onCreate();
    Scoop.setViewBinder(new ButterKnifeViewBinder());
}

Popup module

Let’s make things a little bit tougher now. What about creating a container in charge of displaying popups?

Here are the specs:

  • We are going to use Toast or a similar system for displaying popups.
  • Users should be able to notify, confirm and alert the user (3 different popups).
  • No event bus or similar tool. Only with Scoop.

Any idea so far? 😖

To build this module, we are going to use a new router as a trigger. A new ViewController will be in charge of displaying the different toasters. I am going to use Crouton to display toasters. I do know this library has been deprecated but for this tutorial, installation will be easier.

Ready? Let’s start. We need to design a new layout first:

<?xml version="1.0" encoding="utf-8"?>
<!-- popup.xml - Not used at this point, hence gone. -->
<RelativeLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:visibility="gone"
    xmlns:android="http://schemas.android.com/apk/res/android"
    />

Now, it is the boring part of this new iteration. We need to do some Dagger boilerplating to be capable of injecting the current activity into our PopupController (because Crouton requires it).

@Module
public class ActivityModule {
    private Activity activity;

    public ActivityModule(Activity activity) {
        this.activity = activity;
    }

    @Provides
    public Activity provideActivity() {
        return activity;
    }
}
@Subcomponent(modules = {
    ActivityModule.class
})
public interface ActivityComponent {
    void inject(PopupController popupController);
}

Add the subcomponent to your root component. Then, we must update MainActivity:

public class MainActivity extends AppCompatActivity {
    private static MainActivity instance;

    private ActivityComponent activityComponent;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        instance = this;
        activityComponent = ScoopExampleApplication
            .getApplicationComponent()
            .activityComponent(new ActivityModule(this));
    }

    public static ActivityComponent getActivityComponent() {
        return instance.activityComponent;
    }

Once done, we create our new router, completely identical to the previous one:

public class PopupRouter extends Router {
    public interface ScoopChangedObserver {
        void onScoopChanged(RouteChange routeChange);
    }

    private List<ScoopChangedObserver> observers;

    public PopupRouter(ScreenScooper screenScooper) {
        super(screenScooper);

        observers = new ArrayList<>();
    }

    @Override
    protected void onScoopChanged(RouteChange routeChange) {
        for (ScoopChangedObserver observer : observers) {
            observer.onScoopChanged(routeChange);
        }
    }

    public void observe(ScoopChangedObserver observer) {
        observers.add(observer);
    }

    public void stopObserving(ScoopChangedObserver observer) {
        observers.remove(observer);
    }
}

Update RouterModule after this. This new router is a singleton as well.

Relax and take a deep breath, we’re almost done. Build your new controller, in charge of intercepting the screens and displaying the appropriate toasters:

public class PopupController extends ViewController {

    @Inject
    Activity activity;

    @Override
    protected int layoutId() {
        return R.layout.popup;
    }

    private void showPopup(String message, Style style) {
        Crouton.cancelAllCroutons();
        Crouton
            .makeText(activity, message, style)
            .show();
    }

    @Override
    public void onAttach() {
        super.onAttach();

        MainActivity.getActivityComponent().inject(this);

        Screen screen = Screen.fromController(this);
        if (screen instanceof InformPopupScreen) {
            showPopup(((InformPopupScreen) screen).message, Style.INFO);
        } else if (screen instanceof ConfirmPopupScreen) {
            showPopup(((ConfirmPopupScreen) screen).message, Style.CONFIRM);
        } else if (screen instanceof AlertPopupScreen) {
            showPopup(((AlertPopupScreen) screen).message, Style.ALERT);
        } else {
            Log.e(PopupController.class.getName(), "Invalid screen");
        }
    }

    @Override
    public void onDetach() {
        super.onDetach();
        Crouton.cancelAllCroutons();
    }
}

And finally the new screens:

@Controller(PopupController.class)
public class AlertPopupScreen extends Screen {
    public String message;

    public AlertPopupScreen(String message) {
        this.message = message;
    }
}

@Controller(PopupController.class)
public class ConfirmPopupScreen extends Screen {
    public String message;

    public ConfirmPopupScreen(String message) {
        this.message = message;
    }
}

@Controller(PopupController.class)
public class InformPopupScreen extends Screen {
    public String message;

    public InformPopupScreen(String message) {
        this.message = message;
    }
}

The new UiContainer:

public class PopupUiContainer extends UiContainer {
    private PopupRouter.ScoopChangedObserver observer;

    @Inject
    PopupRouter popupRouter;

    public PopupUiContainer(Context context, AttributeSet attrs) {
        super(context, attrs);

        ScoopExampleApplication.getApplicationComponent().inject(this);
        observer = new PopupRouter.ScoopChangedObserver() {
            @Override
            public void onScoopChanged(RouteChange routeChange) {
                goTo(routeChange);
            }
        };
        popupRouter.observe(observer);
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        popupRouter.stopObserving(observer);
    }
}

Which needs to be appended to root.xml:

<com.coshx.scoopexample.components.PopupUiContainer
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:visibility="gone"
    />

I acknowledge it is a bunch of boilerplate for a simple feature. However, I wanted to keep it simple for both mid-level and senior Android engineers. Of course, you can factorize a bunch of code further to this upgrade.

Great! To test our feature, we are going to replace the existing TextView in our chocolate.xml layout by a ButtonView and add this listener into ChocolateController:

public class ChocolateController extends ViewController {

    @Inject
    PopupRouter popupRouter;

    @Override
    protected int layoutId() {
        return R.layout.chocolate;
    }

    @Override
    public void onAttach() {
        super.onAttach();

        ScoopExampleApplication.getApplicationComponent().inject(this);
        ButterKnife.bind(this, getView());
    }

    @OnClick(R.id.chocolate_button)
    public void onChocolateButtonClick() {
        List<Screen> messages = Arrays.asList(
            new AlertPopupScreen("Chocolate is soooo gooooood"),
            new ConfirmPopupScreen("OMG chocolate"),
            new InformPopupScreen("Nothing is better than chocolate")
        );
        int i = (int) Math.round(Math.random() * (messages.size() - 1));

        popupRouter.goTo(messages.get(i));
    }
}

And you’re all set! The screens are used to pop up a new toaster from a single controller. As I mentioned before, you can observe the 1 screen per controller assertion is wrong. You must see them as states or messages.

In this situation, a new UiContainer is only needed for proceeding the incoming screens that the router is raising. There is no UI element to render, therefore I set a visibility='gone' on both container and inflated layout.

En bref

Of course, there is a sample application with the full codebase of this tutorial. Feel free to post a comment below or open directly an issue on the repo.

If you want to review a concrete application of Scoop, I invite you to check out my latest app.

What about adding a footer?

Here is an extra for this tutorial. Suppose we want to add a tab footer to our app. What are our specifications? If we click on any tab, our app updates both body and footer and display the requested screen. Fine. What happens if the back button is pressed? Again, the UI has to update both components.

How can we implement that in the current system? You might be tempted to append this footer directly to your body. However, if you do so, you strongly tie the components. Also, it means you have to manage the footer’s state in any body’s ViewController. If we want to add a tab header as well, our controllers will result in a mess.

Hence, we have to decouple them. The first solution you might have in mind is the following:

  • Create a new router, container and controller for the footer.
  • Define new screens for the footer.
  • When going to a new body screen, we trigger a new screen for the footer too.

That’s a perfect viable solution but we can make it better. What about keep using our AppRouter but changing its behavior only?

My solution is the following:

  • We define a BodyRouter and a FooterRouter.
  • User keeps using body related screens
  • AppRouter becomes a proxy. It overrides any Scoop router method (goTo, goBack…) and intercepts every incoming screen. Depending on the requested body screen, the router triggers an associated screen on the footer. When going back, both routers move back.

Basically, our new router will be like this:

public class AppRouter extends Router {
    private BodyRouter   bodyRouter;
    private FooterRouter footerRouter;

    AppRouter(BodyRouter bodyRouter, FooterRouter footerRouter) {
        this.bodyRouter = bodyRouter;
        this.footerRouter = footerRouter;
    }

    public void goTo(Screen screen) {
        bodyRouter.goTo(screen);

        // Trigger footer's screen here if needed
    }

    public boolean goBack() {
        footerRouter.goBack();
        return bodyRouter.goBack();
    }
}

I will not walk you through for this iteration. However, you can review my sample application supporting both page (body + footer) and fullscreen layouts here: https://github.com/coshx/scoop-layout-example.