Let's say I have 4 versions of button (primary, er...
# compose
j
Let's say I have 4 versions of button (primary, error, secondary, ...) What is the preferred (more idiomatic?) way to do the API: 1️⃣
Copy code
ButtonPrimary(onClick = {}) { Text("Primary") }
ButtonSecondary(onClick = {}) { Text("Secondary") }
or having it configurable via a property and enum? 2️⃣
Copy code
Button(Kind.Primary, onClick = {}) { Text("Primary") }
Button(Kind.Secondary, onClick = {}) { Text("Primary") }
My SwiftUI friend forces me to the enum way but I would prefer rather the first one (their syntax seems better here - they may use only the
.Primary
token as the value.)
1️⃣ 9
🙌 1
2️⃣ 2
b
I have a mix in my design system because we have multiple dimensions- e.g. we have defined processing, enabled, disabled, confirmed states for three different broad category of button (primary, secondary, tertiary). I don’t think there’s a right answer, it depends a lot on your design system and how you want your team to interact with those buttons
☝️ 1
👍 1
We have three button Composables with signatures like this:
Copy code
@Composable
fun PrimaryButton(
  onClick: () -> Unit,
  modifier: Modifier = Modifier,
  state: ButtonState = ButtonState.DEFAULT,
  size: ButtonSize = ButtonSize.MEDIUM,
  content: @Composable RowScope.() -> Unit
)
d
In my opinion, it depends completely on the developer what approache they choose. There's nothing wrong in both approaches.
Just a little suggestion, if you go with 1st approach, name composables as PrimaryButton(), SecondaryButton() etc as done by @Bryan Herbst. It increases readability
a
I tend to prefer the former, but even on the compose team some people prefer the latter. It comes down to maintenance and where you want to put incentives or friction.
j
Just a little suggestion, if you go with 1st approach, name composables as PrimaryButton(), SecondaryButton() etc as done by @Bryan Herbst. It increases readability
It lowers then a discoverability a bit, doesn't it?
a
style 1 creates incentives to build in layers; no one wants to duplicate the same implementation code for PrimaryButton/SecondaryButton/etc, composable utilities naturally build up in such a design. If you make those inner utilities public API in your system as well, then others can come along and remix them into their own FancyButton, etc.
this means you're out of the critical path of other people's features. No one is going to send you a code review for a new
Kind.Fancy
constant and the code internal to
Button
to respect it. It keeps the inner logic of
Button
simpler to omit those branching paths based on the
Kind
the flip side of this is that in a strict design system, forcing others to come to you as a gatekeeper to add a new kind of button might be a feature.
☝️ 1
But there are additional considerations with style 2 that have to do with the way compose works. Consider:
Copy code
@Composable fun Button(
  kind: Kind,
  onClick: () -> Unit,
  content: @Composable () -> Unit
) {
  // ...
  when (kind) {
    Kind.Primary -> {
      Row(primaryModifiers) {
        content()
      }
    Kind.Secondary -> {
      Row(secondaryModifiers) {
        content()
      }
    }
  }
  // ...
}
🕵️‍♂️ 1
Spot the bug/gotcha 🙂
j
My personal attitude is ~
make wanted things easy, make unwanted things possible
I'm asking rather about the outer API style since both of the approaches can be done the way to allow custom styling (if our team will agree on allowing it), 1. option would probably expose the shared impl. taking explicit styling attributes 2. option would be probably rather a sealed interface with one option called Custom holding the styling attributes - similar to ButtonColors?
a
make wanted things easy, make unwanted things possible
I am also a fan of this philosophy. 🙂 It leads to far fewer emergencies at your doorstep over time.
The bug/gotcha in the example I posted above is that different calls to a
content
function in the implementation details of a composable function with disjoint branching paths have different identity.
If you use style 2, you have to consider what correct behavior of your button is if someone changes that parameter at runtime, shifting a button between primary/secondary on the fly or similar.
And if you write it the way I did in the example above, if someone changes the style on the fly, their content loses all remembered state since it doesn't have the same identity anymore:
Copy code
var style by remember { mutableStateOf(Kind.Primary) }
Button(style, onClick = { style = nextStyle(style) }) {
  var innerState by remember { SomeState() }
  SomeStatefulThing(innerState)
}
every time I click that button,
innerState
will be reset to a fresh initial value, which is probably quite unexpected for a user
a
Plus a third option
Copy code
sealed class StyledButton { ... }

@Composable
fun StyledButton.Primary(...) { ... }

@Composable
fun StyledButton.Secondary(...) { ... }
Pretty similar to the second one, though
Probably can be improved, it was just a quick write of an idea
a
The basic idea of namespacing like that can certainly work, and it sidesteps the whole, "what happens if someone changes the parameter at runtime?" question
it sounds obvious to say it, but any time something is expressed as a parameter you have to handle the case where a caller changes that parameter in a recomposition. This often adds implementation complexity and sometimes API complexity if it's not obvious from the signature how the component should behave
☝️ 2
the whole space of things like initial values is full of examples
j
https://kotlinlang.slack.com/archives/CJLTWPH7S/p1618326021258500?thread_ts=1618322406.250900&cid=CJLTWPH7S This is definitely very important remark I wasn't considering at the beginning. Thank you!
👍 1
👍🏼 1
b
As a bonus to needing to care about changing those parameters- you also gain the ability to create consistent animations between states when those parameters change
a
right, which can cut both ways. The caller might be in a better position to coordinate an animation than the callee, and you start building expectations around that long tail of use cases where the last 10% of the features take 90% of the time/effort/complexity/maintenance, and all of them have to work together
granted, my bias in this comes from often working on very general framework-level components. App code or code within a single organization often assigns very different weights to these things
b
Very true. And I definitely don’t envy the framework-level considerations there!