Compose hierarchy of UI actions based on class type of state passed in

Viewed 167

I am using a composable view that show a view for different types of objects.

@Composable
fun Details(
   liveData: LiveData<State>,
   onAction: ((Action) -> Unit)? = null
) {
   val state = liveData.observeAsState()
   when (state.value) {
      is AuthorState -> {
         AuthorDetails(state, onAction)
      }
      is BookState -> {
         BookDetails(state, onAction)
      } else -> {
         DefaultState(state, onAction)
      }
   }
}


sealed class Action<T> {
   class Name<T>(val id: T): Action<T>()
}

sealed class AuthorAction {
   class Name(val id: Long): Action<Long>()
   class Address(val author: AuthorState): AuthorAction()
}

@Composable
fun AuthorDetails(
   authorState: AuthorState,
   onAction: (AuthorAction) -> Unit
) {
   ...
   onAction(AuthorAction.Address(authorState))
   ...
}

sealed class BookAction {
   class Name(val id: String): Action<String>()
   class Location(val book: BookState): BookAction()
}

@Composable
fun BookDetails(
   bookState: BookState,
   onAction: (BookAction) -> Unit
) {
   ...
   onAction(BookAction.Location(bookState))
   ...
}

And in Activity:

view.bottom_sheet.setContent {
   Details(viewModel.details) { action ->
      when(action) {
         is BookAction -> onBookAction(action)
         is AuthorAction -> onAuthorAction(action)
         is DefaultAction -> onDefaultAction(action)
      }
   }
}

I am trying to define a hierarchy of actions that are based on the state that is passed to the composable.

Problem is the "Details" composable expects an "Action" type, not BookAction or AuthorAction. I have played around with the inheritance, however usually end up with a lot of duplication.

Edit: Using @broot answer, having issues invoking action from composable:

Adding back in "id" template:

sealed interface Action<I>
sealed interface AuthorAction<I> : Action<I>
sealed interface BookAction<I> : Action<I>

class Name<I>(val id: I) : Action<I>, AuthorAction<I>, BookAction<I>
class Address(val author: AuthorState) : AuthorAction<Long>
class Location(val book: BookState) : BookAction<String>

interface State<I, out A : Action>
interface BookState : State<String, BookAction>
interface AuthorState : State<Long, AuthorAction>
interface DefaultState<I> : State<I, Action>

fun <I, A : Action<I> Details(
    liveData: LiveData<State<I, A>>,
    onAction: ((A) -> Unit)? = null
) {
   onAction?.invoke(Name("id"))
}

Doing this gives me a compile error:

Type mismatch. 
Required: A
Found: Action<I>

I can change the "Details" function:

fun <I, A : Action> Details(
    liveData: LiveData<State<I, A>>,
    onAction: ((A: Action<I>) -> Unit)? = null
) {
   onAction?.invoke(Name("id"))
}

However when I do this, action is always "Action" in client calling code, not BookAction or AuthorAction.

1 Answers

There are two problems two solve here.

First is how to structure actions, so they are sealed, but at the same time an action could belong to multiple groups of actions. For example, Name action is a part of AuthorAction, BookAction and also base Action group.

We can do this by replacing sealed classes with sealed interfaces and inheriting from multiple of them. For example:

sealed interface Action
sealed interface AuthorAction : Action
sealed interface BookAction : Action

class Name(val id: String) : Action, AuthorAction, BookAction
class Address(val author: AuthorState) : AuthorAction
class Location(val book: BookState) : BookAction

(I intentionally removed T from Name and Action just to simplify the code and focus on what's important.)

Second problem is how to choose a proper group of actions depending on the type of state object. We can do this by parameterizing state with its related action:

interface State<out A : Action>
interface BookState : State<BookAction>
interface AuthorState : State<AuthorAction>
interface DefaultState : State<Action>

We also have to parameterize the Details() function:

fun <A : Action> Details(
    liveData: LiveData<State<A>>,
    onAction: ((A) -> Unit)? = null
) {
    when (val state = liveData.observeAsState().value) {
        is AuthorState -> {
            onAction?.invoke(Name("foo") as A)
            onAction?.invoke(Address(state) as A)

            // or:
            
            AuthorDetails(state, onAction as ((AuthorAction) -> Unit)?)
        }
        ...
    }
}

Then we can use it like this:

val authorState: LiveData<AuthorState> = TODO()
Details(authorState) { action -> // action is AuthorAction
    when (action) { // when is exhaustive
        is Name -> TODO()
        is Address -> TODO()
    }
}

val bookState: LiveData<BookState> = TODO()
Details(bookState) { action -> // action is BookAction
    when (action) { // when is exhaustive
        is Name -> TODO()
        is Location -> TODO()
    }
}
Related