The Conductor library daily grows in popularity. Unfortunately, however, there’s not a lot of guidance available on the web about using the library, and official sources give only examples. With that in mind, this blog is intended as an introductory course on using Conductor, helping you capitalize on its benefits while avoiding the most significant pitfalls. (This information will be useful for users who already have some experience in Android application development)
Conductor is marketed as a replacement for standard fragments. The primary idea is to wrap a View, thereby providing access to Activity lifecycle methods. Conductor has its own lifecycle, and it’s much easier than that of fragments. But it also has several quirks. (We’ll discuss those quirks a bit later.)
Conductor’s main advantages include the following:

  • The code is simplified.
  • Transactions are executed immediately.
  • An entire application can be built using a single Activity.
  • You are not limited by the architecture of the app
  • Animation can be integrated easily.
  • There’s no need to save a state on configuration changes.

Furthermore, you receive the following features straight out of the box:

  • You have the ability to work with the back stack.
  • Standard Activity callbacks can be accessed very easily.
  • Several standard animations are provided.
  • The lifecycle can be easily linked to RxJava.
  • It allows fast integration with ViewPager.

Next, we are going to discuss several typical example situations which will be relevant in virtually any application. We are also going to explore the lifecycle of a Controller, which is the basic layout building block used by Conductor.

Basics

Let’s start with an easy example.

//File: MainActivity.java
public class MainActivity extends AppCompatActivity {
  private Router router;
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    ViewGroup container = (ViewGroup) findViewById(R.id.controller_container);
    router = Conductor.attachRouter(this, container, savedInstanceState);
    if (!router.hasRootController()) {
      router.setRoot(RouterTransaction.with(new HomeController()));
    }
  }
}
//File: HomeController.java
public class HomeController extends Controller {
  @Override
  protected View onCreateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container) {
    return inflater.inflate(R.layout.controller_home, container, false);
  }
}

After launching the app, a screen with the controller_home layout will appear.
The Controller code is simple; it creates the View. The initialization process is more complex — it even contains a condition. During the process of Activity creation, Conductor::attachRouter attaches the router to our Activity and its lifecycle. It’s worth noting that we are sending not only the context and the container, but also the saved state. The router saves all the Controllers and their states in its stack. That way, if the Activity wasn’t created from the very beginning and was just restored, it’s not necessary to install the root Controller, because it will be restored from the saved state.
Nothing prevents us from removing this check. However, every time we create an Activity, we are going to receive a new Controller and lose all saved data.

States

Let’s take a closer look at understanding how and where states are saved in Conductor. In order to do so, we’ll have to make our example a bit more complex. We’ll do this by creating an action that’s universally useful. We’re going to grow a tree using a tap.

//File: HomeController.java
public class HomeController extends Controller {
  private View tree;
  @NonNull
  @Override
  protected View onCreateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container) {
    View view = inflater.inflate(R.layout.controller_home, container, false);
    tree = view.findViewById(R.id.tree);
    view.setOnClickListener(new View.OnClickListener() {
      @Override
      public void onClick(View v) {
        tree.setVisibility(View.VISIBLE);
      }
    });
    return view;
  }
  @Override
  protected void onDestroyView(@NonNull View view) {
    super.onDestroyView(view);
    tree = null;
  }
}

We haven’t seen anything supernatural yet. Note, however, that we are adding the link to the tree image in onDestroyView in order to make our View collectible by the garbage collector when the Controller is removed from the screen.
What happens if we want to see the tree from the other side? Let’s change the orientation of the screen.

Unfortunately, our tree disappeared. What can we do to make our application work?
The lifecycle of Controllers is linked to a part of the code in the Activity. The fragment is created inside the Conductor::attachRouter function; thus, all requests to the lifecycle methods are linked with it and the Activity, which includes the initialization. The fragment has the setRetainInstance(true) parameter, so this fragment and all Controllers will survive any configuration changes. To make this happen, we simply need to save our state in a Controller variable.

//File: HomeController.java
public class HomeController extends Controller {
  private boolean isGrown = false;
  private View tree;
  @NonNull
  @Override
  protected View onCreateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container) {
    View view = inflater.inflate(R.layout.controller_home, container, false);
    tree = view.findViewById(R.id.tree);
    view.setOnClickListener(new View.OnClickListener() {
      @Override
      public void onClick(View v) {
        isGrown = true;
        update();
      }
    });
    return view;
  }
  @Override
  protected void onAttach(@NonNull View view) {
    super.onAttach(view);
    update();
  }
  private void update() {
    tree.setVisibility(isGrown ? View.VISIBLE : View.GONE);
  }
  @Override
  protected void onDestroyView(@NonNull View view) {
    super.onDestroyView(view);
    tree = null;
  }
}

We added the isGrown variable, which keeps the state of the Controller and the method that updates the current state of the tree image. Once again, there’s nothing difficult here. Let’s check the result.

When we change the configuration, the Activity is eliminated but the fragment is still alive. But what if our Activity has to be killed by the system, and not during the process of changing the configuration? Let’s see what happens when we select the “Don’t keep activities” parameter in the settings.

No miracle happened; we lost all our data. In order to solve this problem, Conductor is duplicating the onSaveInstanceState and onRestoreInstanceState methods. Let’s implement them and confirm that everything works.

//File: HomeController.java
...
@Override
protected void onSaveInstanceState(@NonNull Bundle outState) {
  super.onSaveInstanceState(outState);
  outState.putBoolean("isGrown", isGrown);
}
@Override
protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) {
  super.onRestoreInstanceState(savedInstanceState);
  isGrown = savedInstanceState.getBoolean("isGrown");
}


Hurray! Now we can move on to more complex things, knowing that our tree is safe and sound.

Sending Data

Now let’s determine how many cones can grow on the tree. In order to do that, we will create a Controller that will receive the number of cones and show it.

//File: ConeController.java
public class ConeController extends Controller {
  private int conesCount = 0;
  private TextView textField;
  public ConeController(int conesCount) {
    this.conesCount = conesCount;
  }
  @NonNull
  @Override
  protected View onCreateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container) {
    View view = inflater.inflate(R.layout.controller_cone, container, false);
    textField = (TextView) view.findViewById(R.id.textField);
    return view;
  }
  @Override
  protected void onAttach(@NonNull View view) {
    super.onAttach(view);
    textField.setText("Cones: " + conesCount);
  }
  @Override
  protected void onDestroyView(@NonNull View view) {
    super.onDestroyView(view);
    textField = null;
  }
}

However, this code is not going to be compiled, because one constructor is still missing. Let’s explore the importance of that constructor.
As with fragments, it’s not so easy. The Android system uses reflection to call a constructor without parameters. The Conductor library calls a constructor with a Bundle parameter.
If we simply add a constructor and our Controller is eliminated, Conductor will create it once again, and we will lose all the information about the cones. In order to avoid this, we have to save our data in args. Args are saved by Conductor when the Controller is eliminated.

//File: ConeController.java
public ConeController(int conesCount) {
  this.conesCount = conesCount;
  getArgs().putInt("conesCount", conesCount);
}
public ConeController(@Nullable Bundle args) {
  super(args);
  conesCount = args.getInt("conesCount");
}

Next, we’ll add a call for the Controller and save the number of cones.

//File: HomeController.java
public class HomeController extends Controller {
  private boolean isGrown = false;
  private int conesCount = 42;
  private View tree;
  @NonNull
  @Override
  protected View onCreateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container) {
    View view = inflater.inflate(R.layout.controller_home, container, false);
    tree = view.findViewById(R.id.tree);
    view.setOnClickListener(new View.OnClickListener() {
      @Override
      public void onClick(View v) {
        if (isGrown) {
          getRouter().pushController(RouterTransaction.with(new ConeController(conesCount)));
        } else {
          isGrown = true;
          update();
        }
      }
    });
    return view;
}

The first tap grows the tree while the subsequent taps show the number of cones on it. Finally, in order to switch to another Controller, we have to request pushController from the router in order to add our Controller to the back stack and show it on the screen.

Everything looks great now. Unfortunately, however, we can’t return without restarting the app. Let’s change this.

//File: MainActivity.java
@Override
public void onBackPressed() {
  if (!router.handleBack()) {
    super.onBackPressed();
  }
}

This code will work really smoothly in the app. But let’s look more closely at the internal parts of onBackPressed. Let’s imagine that we don’t want to hide our application during the last stage. The first thing we would like to do is to keep only router.handleBack(). The thing is, when handleBack returns false, it doesn’t mean there was no work done. It means the last Controller was eliminated and the existence of the Activity (or any other container with a router) must be stopped. So you need to remember that even if you delete the root Controller, its View won’t be deleted from the stage. This is made to show a “qualitative” closing animation while the Activity is closing.
That’s why we need to remember the following rule: when handleBack returns false, the owner of the router must be eliminated. Such behavior can be changed by calling the setPopsLastView method with a false parameter.

Receiving Data

Now it’s time to harvest our fruits. It is often required to send data to the previous Controller. Let’s try to collect several cones from the tree.
We could simply send listener to the Controller, but the Android system upsets the apple cart. What if the Controller is going to be eliminated? We won’t be able to restore the link to the listener. The easiest way to solve this problem is to use the setTargetController method, which allows saving the link to another Controller and restoring it after re-creation of the Controller. Common courtesy is not to use the Controller type as a parameter; in fact, it’s preferable to use the interface type as the parameter. When using the interface type as a parameter, the Controller must implement the interface.

//File: ConeController.java
public class ConeController extends Controller {
  private int conesCount = 0;
  private TextView textField;
  public ConeController(int conesCount, T listener) {
    this.conesCount = conesCount;
    getArgs().putInt("conesCount", conesCount);
    setTargetController(listener);
  }
  public ConeController(@Nullable Bundle args) {
    super(args);
    conesCount = args.getInt("conesCount");
  }
  @NonNull
  @Override
  protected View onCreateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container) {
    View view = inflater.inflate(R.layout.controller_cone, container, false);
    textField = (TextView) view.findViewById(R.id.textField);
    view.findViewById(R.id.collectConeButton)
    .setOnClickListener(new View.OnClickListener() {
      @Override
      public void onClick(View v) {
        if (getTargetController() != null) {
          conesCount--;
          getArgs().putInt("conesCount", conesCount);
          update();
        }
      }
    });
    return view;
  }
  @Override
  protected void onAttach(@NonNull View view) {
    super.onAttach(view);
    update();
  }
  @Override
  public boolean handleBack() {
    ConeListener coneListener = (ConeListener)getTargetController();
    coneListener.conesLeft(conesCount);
    return super.handleBack();
  }
  private void update() {
    textField.setText("Cones: " + conesCount);
  }
  @Override
  protected void onDestroyView(@NonNull View view) {
    super.onDestroyView(view);
    textField = null;
  }
  public interface ConeListener {
    void conesLeft(int count);
  }
}
//HomeController.java
public class HomeController extends Controller implements ConeController.ConeListener {
...
@NonNull
@Override
protected View onCreateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container) {
  View view = inflater.inflate(R.layout.controller_home, container, false);
  tree = view.findViewById(R.id.tree);
  view.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
      if (isGrown) {
        getRouter().pushController(RouterTransaction.with(new ConeController(conesCount,
        HomeController.this)));
      } else {
        isGrown = true;
        update();
      }
    }
  });
  return view;
}
@Override
public void conesLeft(int count) {
  conesCount = count;
}

We used the constructor to send the argument, which must inherit Controller, implement the interface of the listener, and save it using the setTargetController method.
When we leave the Controller, we update the number of cones in HomeController by calling conesLeft(…) from the listener. And here’s the result.

Animations

Now we need to make things more attractive. With a subtle movement of the hand, we add animation to the transitions to show and hide the Controller. The basic animation set is available straight out of the box, but you can also use your own by overriding ControllerChangeHandler or inherited classes.

//File: MainActivity.java
getRouter().pushController(RouterTransaction.with(
  new ConeController(conesCount, HomeController.this))
    .popChangeHandler(new FadeChangeHandler())
    .pushChangeHandler(new FadeChangeHandler())
);

Lifecycle Methods

Conductor supports several other lifecycle methods in addition to those indicated in the official documents. They can be useful if you want to implement any unusual behavior. Let’s look at these methods:

  • onAttach — Called when the Controller is shown on the screen
  • onDetach — Called when the Controller is removed from the screen
  • onDestroyView — Called when the View attached to the Controller is eliminated
  • onCreateView — Called when the View must be created for the Controller
  • onDestroy — Called right before the Controller is eliminated

All the methods described below are available in the lifecycle. However, they duplicate the following existing methods of View and Activity classes:

  • onSaveViewState
  • onRestoreViewState
  • onSaveInstanceState
  • onRestoreInstanceState

The following methods are called in during the lifecycle. But it’s better not to rely on them. The order of their calls can depend on the animation, and the only thing which we can handle inside of them is an animation.

  • onChangeStarted — Called right before the animation
  • onChangeEnded — Called right after the animation

When we tried to build a lifecycle, it turned out not to be as easy as it had been described on the official page. Controllers usually have two lifecycles. The first one is the cycle itself that works during the transition between Controllers. The second one works during the creation and the elimination of the Activity. They have several differences; that’s why the diagram became slightly bigger.
Condustor Lifecycle Diagram
Want to learn more about how Distillery is leveraging the latest technologies to streamline the product development process? Let us know!

Top App Creators

Kirill Akhmetov

About the Author

Since Kirill Akhmetov joined Distillery in 2017, he has already become an important part of the team. Back in the day, Kirill started as a C++ programmer, eventually moving on to ActionScript and Game Dev. Today, however, he’s fully devoted to Android development, constantly setting new goals for himself and achieving them in style. In his free time, Kirill enjoys hustle dancing.