If you’ve never heard of the term “Decision Tree UX”, don’t worry, you’re not alone. In fact, no one has ever heard of it, because I made it up! While the term is new, the concept is not, and something most of you might already be familiar with.
Consider a typical enrollment flow as an example. A user begins the flow at a screen with several options for either signing up or logging in. While each choice might lead the user down a different path, all the paths share the same broader goal of authentication. And that folks, is Decision Tree UX. Or put in the form of a formal definition…
Decision Tree UX refers to the flow through a series of decisions and their possible consequences to achieve a single, broader goal.
Isn’t every part of an app a leaf on a decision tree? 🍁🌲
Not exactly. The key to this concept is the “achieving a single goal” part. For example, if you are just navigating around an app, you don’t really have a single goal. Another way of thinking about the “single goal” might be to think of it as “single state” - where all your choices and all the visuals you see are all related to a single state. To better understand this concept, let’s look at this, very scientific, decision tree:
But since were talking about apps here, let’s convert this tree into a prototype.
🔮 rubs magic ball 🔮
Tada 🎉 We have “Decision Tree UX”! While there are several different screens, they are all related to the same “goal” or “state” of a cat’s single decision.
Why Is It Hard to Code This? 🤔
I don’t have a great answer to this question, other than to say, when I was recently presented with the challenge of having to code a Decision Tree UX, I was overwhelmed with the options for how to build it and sometimes having too many options makes things hard.
Some of the things I considered and why I didn’t choose them 🙅
Option: Passing around the single model object from Activity
to
Activity
via Intent
s.
Why I Said “No Way Jose”: For my app the model object was fairly large and
passing it via an Intent
wasn’t really an option. Also, passing around an
object doesn’t allow for a higher level understanding of what is happening.
Option: Inserting some data store into each Activity
so they might access
the same object and pass the particular models’ id
via Intent
s.
Why I said “No Way Jose”: This was my runner up. What I didn’t totally love
about this option was how many switch statements I was going to have to put
into each Activity
. Depending on where the user was coming from, there were
several options for where to send them next, and I was going to have to put a
lot of, what I will call, “higher level logic” about the broader flow in each
individual Activity
.
Ingredients for a solution 📋
With my list of “what I don’t like about other options” in hand, I set forth to find a solution with the following features:
- Testable! Decisions trees are complicated, so testing was super important
- Types! I knew the more types I had the more explicit I could be in my testing
- All traversal logic (where to go when a button is tapped) in a single place, to make testing simple and clear
As I thought about these requirements and how to solve for them, I realized I
was really describing a parent / child relationship - where the parent held
the state and acted like a puppeteer moving its children around. That thinking
led me to choose Fragment
s as my means for implementation, because the
relationship between Activity
s and Fragment
s can mimic that of a parent /
child.
Da Code 💻
Now that we understand the requirements and the things were trying to avoid, let’s start coding.
Using the above cat decision app as an example, let’s begin with the
Fragment
s. The above app has four distinct screens, so let’s make four
Fragment
s.
StartFragment
QuestionFragment
AnswerAFragment
AnswerBFragment
Now we know the parent Activity
, let’s call it DecisionActivity
, will
initially begin by attaching StartFragment
but what happens when a user taps
the start button, and how can we test that?
The answer? Interfaces! Let’s give StartFragment
an interface, called
StartDelegate
, to call whenever the start button is clicked.
interface StartDelegate {
fun onStartClick()
}
class StartFragment : Fragment() {
lateinit var delegate: StartDelegate
override fun onViewCreated(view: View?, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val startButton = view?.findViewById(R.id.start_button)
startButton?.setOnClickListener { delegate.onStartClick() }
}
}
But who will listen for that callback? The DecisionActivity
is not a great
candidate since unit testing with Activity
s is not always easy, so let’s
make a class to manage all the coordination logic that we can more easily mock
and test. Say hello 👋toDecisionCoordinator
.
class DecisionCoordinator() {
fun startFlow() {
val fragment = StartFragment.newInstance()
fragment.delegate = ??
}
}
Before we implement StartDelegate
let’s consider our options for who should
be the one conforming to it. One option would be to have DecisionCoordinator
conform. But that could quickly get messy since onStartClick
isn’t terribly
specific and theoretically other Fragment
s could also have a StartDelegate
,
which puts us back in a situation of having lots of switch statements in our
code. Another option, and the option I chose, is to use inner classes. Remember
earlier when I said I liked types? Well here they are folks! By having some
inner class confom to StartDelegate
we have a single class whose sole
responsibility is handling the feedback from the StartFragment
. Single
Responsibility FTW 💪
Now let’s see the updated DecisionCoordinator
with that inner class:
class DecisionCoordinator() {
fun startFlow() {
val fragment = StartFragment.newInstance()
fragment.delegate = StartCoordinator()
}
inner class StartCoordinator : StartDelegate {
override fun onStartClick() {
//handle where to go when start is clicked
}
}
}
You might notice that we are not actually showing any Fragment
s yet, and
there are two reasons for that. First, we are not “in” an Activity
we don’t
have access to a FragmentManager
which can show a Fragment
. Second, we want
to be able to test everything remember? So we want a solution that will allow
us to test that the correct Fragment
s are being shown at the right times. Say
hello 👋 to FragmentRouter
.
interface FragmentRouter {
fun showFragment(fragment: Fragment)
}
Here’s what happens when we inject FragmentRouter
into the
DecisionCoordinator
.
+ class DecisionCoordinator(val fragmentRouter: FragmentRouter) {
fun startFlow() {
val fragment = StartFragment.newInstance()
fragment.delegate = StartCoordinator()
+ fragmentRouter.showFragment(fragment)
}
inner class StartCoordinator : StartDelegate {
override fun onStartClick() {
//handle where to go when start is clicked
}
}
Now when we test our DecisionCoordinator
we can mock the fragmentRouter
to
assert we are showing the correct Fragment
s💃 But before we move onto the
tests, let’s fill out the rest of the DecisionCoordinator
with the remaining
logic:
class DecisionCoordinator(val fragmentRouter: FragmentRouter) {
fun startDecision() {
val fragment = StartFragment.newInstance()
fragment.delegate = StartCoordinator()
fragmentRouter.showFragment(fragment)
}
inner class StartCoordinator : StartDelegate {
override fun onStartClick() {
val fragment = QuestionFragment.newInstance()
fragment.delegate = QuestionCoordinator()
fragmentRouter.showFragment(fragment)
}
}
inner class QuestionCoordinator : QuestionDelegate {
override fun onYesClick() {
val fragment = AnswerA.newInstance()
fragmentRouter.showFragment(fragment)
}
override fun onNoClick() {
val fragment = AnswerB.newInstance()
fragmentRouter.showFragment(fragment)
}
}
}
And finally, let’s take a look at those tests 👀
class DecisionCoordinatorTest {
val fragmentRouter = mock<FragmentRouter>()
@Test
fun testStartCoordinator_onStartClick() {
val decisionCoordinator = DecisionCoordinator(fragmentRouter)
decisionCoordinator.StartCoordinator().onStartClick()
verify(fragmentRouter).showFragment(isA<QuestionFragment>())
verify(fragmentRouter).showFragment(check {
assertTrue { (it as QuestionFragment).delegate is QuestionCoordinator }
})
}
}
A sight for sore eyes, isn’t it? Our test is readable and clear and the
DecisionCoordinator
fulfills all of our previous requirements. And that is a
solid day’s work folks! Till next time 👋