Skip to content

6. Using custom views instead of fragments

Gabor Varadi edited this page Jun 1, 2020 · 3 revisions

In order to create a view-based setup, using the DefaultStateChanger and Navigator is the easiest way to start out with.

Here is a step-by-step guide to using views.

Steps

Creating a custom viewgroup

To create a custom viewgroup, you need to create a layout file as you generally do, for example layout/hello_world_view.xml.

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <TextView
        android:id="@+id/hello_world_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/hello_world"
        android:layout_centerInParent="true" />
</RelativeLayout>

But you also create a corresponding class for it that extends your root viewgroup:

public class HelloWorldView extends RelativeLayout {
    public HelloWorldView(@NonNull Context context) {
        super(context);
        init(context);
    }

    public HelloWorldView(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init(context);
    }

    public HelloWorldView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }

    @TargetApi(21)
    public HelloWorldView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr, @StyleRes int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        init(context);
    }

    private void init(Context context) {
        if(!isInEditMode()) {
            // ... get key, inject from dagger component, etc.
        }
    }

    private HelloWorldViewBinding binding;

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        binding = HelloWorldViewBinding.bind(this);
    }
}

And most importantly, once you've created this custom viewgroup, you want to replace the root in your layout XML file.

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"

to

<your.packages.HelloWorldView xmlns:android="http://schemas.android.com/apk/res/android"
   ...>
</your.packages.HelloWorldView>

This means when your layout XML is inflated, it'll create your custom viewgroup, where you can handle its views' events.

Creating a key, and associating it with a custom viewgroup

Creating a key

Keys are immutable value objects that represent your state in your application (where you are and where you've been).

Generally this should be Parcelable (or a KeyParceler must be specified to make it be Parcelable).

If you use simple-stack in Java, then your keys will typically look somewhat like this:

@AutoValue
public abstract class HelloWorldKey extends BaseKey {
    public static HelloWorldKey create() {
        return new AutoValue_HelloWorldKey();
    }

    @Override
    public int layout() {
        return R.layout.hello_world_view;
    }
}

Where BaseKey is

public abstract class BaseKey implements DefaultViewKey, Parcelable {
    @Override
    public ViewChangeHandler viewChangeHandler() {
        return new SegueViewChangeHandler();
    }
}

Setting up auto-value

For AutoValue to work, you need to add it as a compileOnly and annotationProcessor dependency. The samples use auto-parcel to make them Parcelable, but with Kotlin, you can use @Parcelize data class.

dependencies {
    ....
    provided "com.google.auto.value:auto-value:1.4.1"
    annotationProcessor "com.google.auto.value:auto-value:1.4.1"
}

Then it'll just work!

For additional parameters in the key, you just for example want to add a public abstract String param(); method, as you normally would with auto-value.

Accessing a key inside a custom viewgroup

Considering you generally add additional parameters needed by your view to the key (think of it like a typed Intent), the DefaultStateChanger inflates the view using stateChange.createContext() (which creates a KeyContextWrapper) as its context, which allows you to use Backstack.getKey(context) to obtain the key.

For example,

public class HelloWorldView extends RelativeLayout {
    // ...
   
    HelloWorldKey helloWorldKey;

    private void init(Context context) {
        if(!isInEditMode()) {
            helloWorldKey = Backstack.getKey(context);
        }
    }

Installing the navigator for handling backstack

In your Activity, you generally want to do the following, or something similar:

public class MainActivity
        extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Navigator.install(this, findViewById(R.id.root), History.single(HelloWorldKey.create()));
    }
}

Navigation

Navigating between screens with Navigator

If you're using Navigator, then you can easily access the backstack with Navigator.getBackstack(context).

binding.helloButton.setOnClickListener((view) -> {
    Navigator.getBackstack(view.getContext()).goTo(OtherKey.create());
});

Animating the state change

By default, the library provides the following view change handlers (for both state change directions):

  • AnimatorViewChangeHandler: base class for animations based on AnimatorSet
  • FadeViewChangeHandler: a basic fade animation between two views
  • NoOpViewChangeHandler: just swap the two views out
  • SegueViewChangeHandler: left-right and right-left animation

Additional custom animations are possible by implementing the ViewChangeHandler interface.

However, this is used by DefaultStateChanger, which means that using it is entirely optional.

Understanding the defaults

In Simple-Stack, the behavior of DefaultStateChanger is the following:

  • it persists the state of the previous view (both hierarchy state, and by Bundleable interface if implemented)

  • it inflates the specified layout, then restores the state of the new view (both hierarchy state, and by Bundleable interface if implemented)

  • and executes a "view change" in-between to animate this transition using the specified "view change handler".

Of course, for this to work, and in order to use DefaultStateChanger, your state keys must implement DefaultViewKey, which specifies the layout resource id, and the view change handler.

The default state changer uses a KeyContextWrapper as the base context of your view, thus allowing you to obtain the key associated with your view directly via Backstack.getKey(getContext()).

DefaultViewKey

By default, DefaultViewKeys provide the layout to inflate, and the view change handler used during forward and backward animation.

In the following example, this is an auto-value generated immutable class that is parcelable, and also properly implements equals() and hashCode().

@AutoValue
public abstract class FirstKey
        implements DefaultViewKey, Parcelable {
    public static FirstKey create() {
        return new AutoValue_FirstKey();
    }

    @Override
    public int layout() {
        return R.layout.first_view;
    }

    @Override
    public ViewChangeHandler viewChangeHandler() {
        return new SegueViewChangeHandler();
    }
}