Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Composing an API with Kotlin (Kotlin Budapest Meetup 2022 October)

Composing an API with Kotlin (Kotlin Budapest Meetup 2022 October)

Kotlin offers many powerful language features to API authors. We’ll explore how Jetpack Compose’s syntax builds on these features, and how its APIs were shaped by Kotlin idioms and conventions. These ideas and best practices give you a deeper understanding of the language, and they are valuable for any Kotlin developer designing APIs – which is actually every Kotlin developer!

More details and resources: https://zsmb.co/talks/composing-an-api-with-kotlin/

Marton Braun

October 18, 2022
Tweet

More Decks by Marton Braun

Other Decks in Programming

Transcript

  1. Márton Braun @zsmb13
    Developer Relations Engineer
    Google
    Composing
    an API with
    Kotlin

    View Slide

  2. Jetpack Compose
    is Android’s modern
    toolkit for building
    native UI.
    d.android.com/compose

    View Slide

  3. What is Jetpack Compose?
    @Composable
    fun Greeting(name: String) {
    Text(text = "Hello $name!")
    }

    View Slide

  4. What is Jetpack Compose?
    @Composable
    fun Greeting(name: String) {
    Text(text = "Hello $name!")
    }
    Hello Android!

    View Slide

  5. What is Jetpack Compose?
    @Composable
    fun Greeting(name: String) {
    Text(text = "Hello $name!")
    }
    Hello Compose!

    View Slide

  6. class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
    DevDayDemoTheme {
    Surface(
    modifier = Modifier.fillMaxSize(),
    color = MaterialTheme.colors.background,
    ) {
    Greeting(name = "Android")
    }
    }
    }
    }
    }
    What is Jetpack Compose?

    View Slide

  7. class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
    DevDayDemoTheme {
    Surface(
    modifier = Modifier.fillMaxSize(),
    color = MaterialTheme.colors.background,
    ) {
    Greeting(name = "Android")
    }
    }
    }
    }
    }
    What is Jetpack Compose?

    View Slide

  8. What is Jetpack Compose?

    View Slide


  9. What is Jetpack Compose?
    Declarative

    View Slide



  10. What is Jetpack Compose?
    Declarative
    Built with Kotlin

    View Slide




  11. What is Jetpack Compose?
    Declarative
    Built with Kotlin
    Open source

    View Slide

  12. // ...
    Extension functions
    class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
    }
    }
    }
    DevDayDemoTheme {
    Surface(
    modifier = Modifier.fillMaxSize(),
    color = MaterialTheme.colors.background,
    ) {
    Greeting(name = "Android")
    }
    }

    View Slide

  13. DevDayDemoTheme {
    Surface(
    modifier = Modifier.fillMaxSize(),
    color = MaterialTheme.colors.background,
    ) {
    Greeting(name = "Android")
    }
    }
    class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
    }
    }
    }
    public fun ComponentActivity.setContent(
    parent: CompositionContext? = null,
    content: @Composable () -> Unit
    )
    Extension functions
    // ...

    View Slide

  14. public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
    )
    public fun ComponentActivity.setContent(
    parent: CompositionContext? = null,
    content: @Composable () -> Unit
    )
    Extension functions

    View Slide

  15. Box(
    modifier = Modifier
    .size(216.dp)
    .background(
    brush = Brush.radialGradient(
    colors = listOf(Color.Black, Color.White)
    )
    )
    .padding(bottom = 12.dp)
    )
    Extension functions

    View Slide

  16. Box(
    modifier = Modifier
    .size(216.dp)
    .background(
    brush = Brush.radialGradient(
    colors = listOf(Color.Black, Color.White)
    )
    )
    .padding(bottom = 12.dp)
    )
    Extension functions
    @Composable
    fun Greeting(
    name: String,
    modifier: Modifier = ...
    )

    View Slide

  17. Box(
    modifier =
    .size(216.dp)
    .background(
    brush = Brush.radialGradient(
    colors = listOf(Color.Black, Color.White)
    )
    )
    .padding(bottom = 12.dp)
    )
    Modifier.
    Extension functions

    View Slide

  18. Modifier.
    Extension functions

    View Slide

  19. @Stable
    interface Modifier {
    infix fun then(other: Modifier): Modifier =
    if (other === Modifier) this else CombinedModifier(this, other)
    companion object : Modifier {
    override infix fun then(other: Modifier): Modifier = other
    }
    }
    Extension functions

    View Slide

  20. @Stable
    interface Modifier {
    infix fun then(other: Modifier): Modifier =
    if (other === Modifier) this else CombinedModifier(this, other)
    companion object : Modifier {
    override infix fun then(other: Modifier): Modifier = other
    }
    }
    Extension functions

    View Slide

  21. @Stable
    interface Modifier {
    infix fun then(other: Modifier): Modifier =
    if (other === Modifier) this else CombinedModifier(this, other)
    companion object : Modifier {
    override infix fun then(other: Modifier): Modifier = other
    }
    }
    Modifier.
    Extension functions

    View Slide

  22. @Stable
    interface Modifier {
    infix fun then(other: Modifier): Modifier =
    if (other === Modifier) this else CombinedModifier(this, other)
    companion object : Modifier {
    override infix fun then(other: Modifier): Modifier = other
    }
    }
    Extension functions
    @Composable
    fun Greeting(
    name: String,
    modifier: Modifier = Modifier
    )

    View Slide

  23. Modifier.
    Extension functions

    View Slide

  24. package androidx.compose.foundation
    fun Modifier.background(color: Color, shape: Shape = RectangleShape)
    Extension functions

    View Slide

  25. package androidx.compose.foundation
    fun Modifier.background(color: Color, shape: Shape = RectangleShape)
    package androidx.compose.foundation.gestures
    fun Modifier.scrollable(state: ScrollableState, orientation: Orientation)
    fun Modifier.draggable(state: DraggableState, orientation: Orientation)
    Extension functions

    View Slide

  26. package androidx.compose.foundation
    fun Modifier.background(color: Color, shape: Shape = RectangleShape)
    package androidx.compose.foundation.gestures
    fun Modifier.scrollable(state: ScrollableState, orientation: Orientation)
    fun Modifier.draggable(state: DraggableState, orientation: Orientation)
    package androidx.compose.foundation.layout
    fun Modifier.padding(all: Dp)
    fun Modifier.size(width: Dp, height: Dp)
    Extension functions

    View Slide

  27. package androidx.compose.foundation
    fun Modifier.background(color: Color, shape: Shape = RectangleShape)
    package androidx.compose.foundation.gestures
    fun Modifier.scrollable(state: ScrollableState, orientation: Orientation)
    fun Modifier.draggable(state: DraggableState, orientation: Orientation)
    package androidx.compose.foundation.layout
    fun Modifier.padding(all: Dp)
    fun Modifier.size(width: Dp, height: Dp)
    package androidx.compose.ui.draw
    fun Modifier.clip(shape: Shape)
    fun Modifier.rotate(degrees: Float)
    fun Modifier.scale(scaleX: Float, scaleY: Float)
    Extension functions

    View Slide

  28. package androidx.compose.foundation
    fun Modifier.background(color: Color, shape: Shape = RectangleShape)
    package androidx.compose.foundation.gestures
    fun Modifier.scrollable(state: ScrollableState, orientation: Orientation)
    fun Modifier.draggable(state: DraggableState, orientation: Orientation)
    package androidx.compose.foundation.layout
    fun Modifier.padding(all: Dp)
    fun Modifier.size(width: Dp, height: Dp)
    package androidx.compose.ui.draw
    fun
    fun Modifier.rotate(degrees: Float)
    fun Modifier.scale(scaleX: Float, scaleY: Float)
    Extension functions
    Modifier.clip(shape: Shape)

    View Slide

  29. Naming conventions
    Modifier.clip(shape: Shape)

    View Slide

  30. Modifier.clip(CircleShape)
    Naming conventions

    View Slide

  31. Modifier.clip(CircleShape)
    val CircleShape = RoundedCornerShape(50)
    Naming conventions

    View Slide

  32. Modifier.clip(CircleShape)
    val circleShape = RoundedCornerShape(50)
    Naming conventions

    View Slide

  33. Modifier.clip(CircleShape)
    val CIRCLE_SHAPE = RoundedCornerShape(50)
    Naming conventions

    View Slide

  34. Modifier.clip(CircleShape)
    val CircleShape = RoundedCornerShape(50)
    Naming conventions

    View Slide

  35. Modifier.clip(CircleShape)
    val CircleShape = RoundedCornerShape(50)
    class RoundedCornerShape(
    topStart: CornerSize,
    topEnd: CornerSize,
    bottomEnd: CornerSize,
    bottomStart: CornerSize
    )
    Naming conventions

    View Slide

  36. Modifier.clip(CircleShape)
    val CircleShape = RoundedCornerShape(50)
    fun RoundedCornerShape(percent: Int) = ...
    Naming conventions

    View Slide

  37. Modifier.clip(CircleShape)
    val CircleShape = RoundedCornerShape(50)
    fun RoundedCornerShape(percent: Int) = ...
    Naming conventions

    View Slide

  38. Modifier.clip(CircleShape)
    val CircleShape = RoundedCornerShape(50)
    fun RoundedCornerShape(percent: Int) = ...
    fun RoundedCornerShape(size: Float) = ...
    fun RoundedCornerShape(size: Dp) = ...
    Naming conventions

    View Slide

  39. fun RoundedCornerShape(
    topStart: Dp = 0.dp,
    topEnd: Dp = 0.dp,
    bottomEnd: Dp = 0.dp,
    bottomStart: Dp = 0.dp
    ) = RoundedCornerShape(
    topStart = CornerSize(topStart),
    topEnd = CornerSize(topEnd),
    bottomEnd = CornerSize(bottomEnd),
    bottomStart = CornerSize(bottomStart)
    )
    Naming conventions

    View Slide

  40. fun RoundedCornerShape(
    topStart: Dp = 0.dp,
    topEnd: Dp = 0.dp,
    bottomEnd: Dp = 0.dp,
    bottomStart: Dp = 0.dp
    ) = RoundedCornerShape(
    topStart = CornerSize(topStart),
    topEnd = CornerSize(topEnd),
    bottomEnd = CornerSize(bottomEnd),
    bottomStart = CornerSize(bottomStart)
    )
    Hello world
    Naming conventions

    View Slide

  41. fun RoundedCornerShape(
    topStart: Dp = 0.dp,
    topEnd: Dp = 0.dp,
    bottomEnd: Dp = 0.dp,
    bottomStart: Dp = 0.dp
    ) = RoundedCornerShape(
    topStart = CornerSize(topStart),
    topEnd = CornerSize(topEnd),
    bottomEnd = CornerSize(bottomEnd),
    bottomStart = CornerSize(bottomStart)
    )
    RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomEnd = 16.dp)
    Hello world
    Naming conventions

    View Slide

  42. fun RoundedCornerShape(
    topStart: Dp = 0.dp,
    topEnd: Dp = 0.dp,
    bottomEnd: Dp = 0.dp,
    bottomStart: Dp = 0.dp
    ) = RoundedCornerShape(
    topStart = CornerSize(topStart),
    topEnd = CornerSize(topEnd),
    bottomEnd = CornerSize(bottomEnd),
    bottomStart = CornerSize(bottomStart)
    )
    RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomEnd = 16.dp)
    Hello world
    RoundedCornerShape(16.dp).copy(bottomStart = CornerSize(0.dp))
    Naming conventions

    View Slide

  43. Column(modifier = modifier.fillMaxSize()) {
    JetNewsLogo(Modifier
    .padding(16.dp)
    .align(Alignment.CenterHorizontally)
    )
    Divider()
    DrawerButton(
    icon = Icons.Filled.Home,
    label = stringResource(id = R.string.home_title),
    isSelected = currentRoute == JetnewsDestinations.HOME_ROUTE,
    action = { navigateToHome(); closeDrawer() }
    )
    DrawerButton(
    icon = Icons.Filled.ListAlt,
    label = stringResource(id = R.string.interests_title),
    isSelected = currentRoute == JetnewsDestinations.INTERESTS_ROUTE,
    action = { navigateToInterests(); closeDrawer() }
    )
    }
    Naming conventions

    View Slide

  44. Column(modifier = modifier.fillMaxSize()) {
    JetNewsLogo(Modifier
    .padding(16.dp)
    .align(Alignment.CenterHorizontally)
    )
    Divider()
    DrawerButton(
    icon = Icons.Filled.Home,
    label = stringResource(id = R.string.home_title),
    isSelected = currentRoute == JetnewsDestinations.HOME_ROUTE,
    action = { navigateToHome(); closeDrawer() }
    )
    DrawerButton(
    icon = Icons.Filled.ListAlt,
    label = stringResource(id = R.string.interests_title),
    isSelected = currentRoute == JetnewsDestinations.INTERESTS_ROUTE,
    action = { navigateToInterests(); closeDrawer() }
    )
    }
    Naming conventions

    View Slide

  45. Scopes
    Column(modifier = modifier.fillMaxSize()) {
    JetNewsLogo(Modifier
    .padding(16.dp)
    .align(Alignment.CenterHorizontally)
    )
    Divider()
    DrawerButton(
    icon = Icons.Filled.Home,
    label = stringResource(id = R.string.home_title),
    isSelected = currentRoute == JetnewsDestinations.HOME_ROUTE,
    action = { navigateToHome(); closeDrawer() }
    )
    DrawerButton(
    icon = Icons.Filled.ListAlt,
    label = stringResource(id = R.string.interests_title),
    isSelected = currentRoute == JetnewsDestinations.INTERESTS_ROUTE,
    action = { navigateToInterests(); closeDrawer() }
    )
    }

    View Slide

  46. Column(modifier = modifier.fillMaxSize()) {
    JetNewsLogo(Modifier
    .padding(16.dp)
    .align(Alignment.CenterHorizontally)
    )
    Divider()
    DrawerButton(
    icon = Icons.Filled.Home,
    label = stringResource(id = R.string.home_title),
    isSelected = currentRoute == JetnewsDestinations.HOME_ROUTE,
    action = { navigateToHome(); closeDrawer() }
    )
    DrawerButton(
    icon = Icons.Filled.ListAlt,
    label = stringResource(id = R.string.interests_title),
    isSelected = currentRoute == JetnewsDestinations.INTERESTS_ROUTE,
    action = { navigateToInterests(); closeDrawer() }
    )
    }
    this: ColumnScope
    Scopes

    View Slide

  47. @LayoutScopeMarker
    @Immutable
    interface ColumnScope {
    @Stable
    fun Modifier.align(alignment: Alignment.Horizontal): Modifier
    }
    Scopes

    View Slide

  48. @LayoutScopeMarker
    @Immutable
    interface ColumnScope {
    @Stable
    fun Modifier.align(alignment: Alignment.Horizontal): Modifier
    }
    @DslMarker
    annotation class LayoutScopeMarker
    Scopes

    View Slide

  49. Column(modifier = modifier.fillMaxSize()) {
    JetNewsLogo(
    Modifier
    .padding(16.dp)
    .align(Alignment.CenterHorizontally)
    )
    }
    Scopes
    this: ColumnScope

    View Slide

  50. Column(modifier = modifier.fillMaxSize()) {
    Button(onClick = { /* ... */ }) {
    JetNewsLogo(
    Modifier
    .padding(16.dp)
    .align(Alignment.CenterHorizontally)
    )
    }
    }
    Scopes
    this: ColumnScope

    View Slide

  51. val sizeInPx = 16.dp.toPx()
    Scopes

    View Slide

  52. Scopes
    val sizeInPx = 16.dp.toPx()

    View Slide

  53. val sizeInPx = with(LocalDensity.current) {
    }
    this: Density
    Scopes
    16.dp.toPx()

    View Slide

  54. val sizeInPx = with(LocalDensity.current) {
    16.dp.toPx()
    }
    this: Density
    @Immutable
    @Stable
    val density: Float
    @Stable
    Scopes
    interface Density {
    }
    fun Dp.toPx(): Float = value * density
    object CustomGridCells : GridCells {
    override fun Density.calculateCrossAxisCellSizes(
    availableSize: Int,

    View Slide

  55. object CustomGridCells : GridCells {
    override fun Density.calculateCrossAxisCellSizes(
    availableSize: Int,
    spacing: Int
    ): List {
    val sideCellSize = 60.dp.roundToPx()
    return listOf(
    sideCellSize,
    availableSize - 2 * sideCellSize,
    sideCellSize,
    )
    }
    }
    Scopes
    fun Dp.toPx(): Float = value * density
    interface Density {
    }

    View Slide

  56. Not a real Compose API
    context(Density)
    object CustomGridCells : GridCells {
    context(Density) override fun calculateCrossAxisCellSizes(
    availableSize: Int,
    spacing: Int
    ): List {
    val sideCellSize = 60.dp.roundToPx()
    return listOf(
    sideCellSize,
    availableSize - 2 * sideCellSize,
    sideCellSize,
    )
    }
    }
    Scopes – with context receivers?
    fun Dp.toPx(): Float = value * density

    View Slide

  57. Inline classes

    View Slide

  58. Inline classes
    @JvmInline
    value class Dp(val value: Float) {
    inline operator fun plus(other: Dp)
    inline operator fun minus(other: Dp)
    inline operator fun div(other: Float): Dp
    inline operator fun div(other: Int): Dp
    inline operator fun div(other: Dp): Float
    }
    inline val Int.dp: Dp get() = Dp(value = this.toFloat())
    inline operator fun Int.times(other: Dp) = Dp(this * other.value)

    View Slide

  59. val availableWidth: Dp = 200.dp
    val itemWidth: Dp = 30.dp
    val itemCount: Int = (availableWidth / itemWidth).toInt()
    val remaining: Dp = (availableWidth - itemCount * itemWidth)
    val separatorWidth: Dp = remaining / (itemCount - 1)
    Inline classes

    View Slide

  60. float availableWidth = Dp.constructor-impl((float) 200);
    float itemWidth = Dp.constructor-impl((float) 30);
    int itemCount = (int)(availableWidth / itemWidth);
    float other$iv = Dp.constructor-impl((float) itemCount * itemWidth);
    float remaining = Dp.constructor-impl(availableWidth - other$iv);
    float separatorWidth = Dp.constructor-impl(remaining / (float) (itemCount - 1));
    val availableWidth: Dp = 200.dp
    val itemWidth: Dp = 30.dp
    val itemCount: Int = (availableWidth / itemWidth).toInt()
    val remaining: Dp = (availableWidth - itemCount * itemWidth)
    val separatorWidth: Dp = remaining / (itemCount - 1)
    Inline classes

    View Slide

  61. float availableWidth = Dp.constructor-impl((float) 200);
    float itemWidth = Dp.constructor-impl((float) 30);
    int itemCount = (int)(availableWidth / itemWidth);
    float other$iv = Dp.constructor-impl((float) itemCount * itemWidth);
    float remaining = Dp.constructor-impl(availableWidth - other$iv);
    float separatorWidth = Dp.constructor-impl(remaining / (float) (itemCount - 1));
    val availableWidth: Dp = 200.dp
    val itemWidth: Dp = 30.dp
    val itemCount: Int = (availableWidth / itemWidth).toInt()
    val remaining: Dp = (availableWidth - itemCount * itemWidth)
    val separatorWidth: Dp = remaining / (itemCount - 1)
    Inline classes

    View Slide

  62. float availableWidth = Dp.constructor-impl((float) 200);
    float itemWidth = Dp.constructor-impl((float) 30);
    int itemCount = (int)(availableWidth / itemWidth);
    float other$iv = Dp.constructor-impl((float) itemCount * itemWidth);
    float remaining = Dp.constructor-impl(availableWidth - other$iv);
    float separatorWidth = Dp.constructor-impl(remaining / (float) (itemCount - 1));
    public final class Dp {
    private final float value;
    public static float constructor_impl(float value) {
    return value;
    }
    }
    Inline classes

    View Slide

  63. Coroutines

    View Slide

  64. Coroutines
    chris.banes.dev/suspending-views/

    View Slide

  65. val listState = rememberLazyListState()
    val coroutineScope = rememberCoroutineScope()
    Box {
    LazyColumn(state = listState) {
    // ...
    }
    AnimatedVisibility(/* ... */) {
    ScrollToTopButton(
    onClick = {
    coroutineScope.launch {
    listState.animateScrollToItem(index = 0)
    }
    }
    )
    }
    }
    Coroutines

    View Slide

  66. val listState = rememberLazyListState()
    val coroutineScope = rememberCoroutineScope()
    Box {
    LazyColumn(state = listState) {
    // ...
    }
    AnimatedVisibility(/* ... */) {
    ScrollToTopButton(
    onClick = {
    coroutineScope.launch {
    listState.animateScrollToItem(index = 0)
    }
    }
    )
    }
    }
    Coroutines

    View Slide

  67. val listState = rememberLazyListState()
    val coroutineScope = rememberCoroutineScope()
    Box {
    LazyColumn(state = listState) {
    // ...
    }
    AnimatedVisibility(/* ... */) {
    }
    }
    Coroutines
    ScrollToTopButton(
    onClick = {
    coroutineScope.launch {
    listState.animateScrollToItem(index = 0)
    }
    }
    )

    View Slide

  68. val listState = rememberLazyListState()
    val coroutineScope = rememberCoroutineScope()
    Box {
    LazyColumn(state = listState) {
    // ...
    }
    AnimatedVisibility(/* ... */) {
    }
    }
    Coroutines
    ScrollToTopButton(
    onClick = {
    coroutineScope.launch {
    listState.animateScrollToItem(index = 0)
    }
    }
    )

    View Slide

  69. ScrollToTopButton(
    onClick = {
    coroutineScope.launch {
    listState.animateScrollToItem(index = 0)
    }
    }
    )
    Coroutines

    View Slide

  70. ScrollToTopButton(
    onClick = {
    coroutineScope.launch {
    listState.animateScrollToItem(index = 0)
    snackbarHostState.showSnackbar(
    message = "Scrolled to the top!",
    )
    }
    }
    )
    Coroutines

    View Slide

  71. ScrollToTopButton(
    onClick = {
    coroutineScope.launch {
    listState.animateScrollToItem(index = 0)
    snackbarHostState.showSnackbar(
    message = "Scrolled to the top!",
    actionLabel = "Revert",
    )
    }
    }
    )
    Coroutines

    View Slide

  72. ScrollToTopButton(
    onClick = {
    coroutineScope.launch {
    listState.animateScrollToItem(index = 0)
    val result = snackbarHostState.showSnackbar(
    message = "Scrolled to the top!",
    actionLabel = "Revert",
    )
    if (result == SnackbarResult.ActionPerformed) {
    }
    }
    }
    )
    Coroutines

    View Slide

  73. ScrollToTopButton(
    onClick = {
    coroutineScope.launch {
    listState.animateScrollToItem(index = 0)
    val result = snackbarHostState.showSnackbar(
    message = "Scrolled to the top!",
    actionLabel = "Revert",
    )
    if (result == SnackbarResult.ActionPerformed) {
    }
    }
    }
    )
    Coroutines
    val firstItemIndex = listState.firstVisibleItemIndex
    val firstItemOffset = listState.firstVisibleItemScrollOffset

    View Slide

  74. ScrollToTopButton(
    onClick = {
    coroutineScope.launch {
    listState.animateScrollToItem(index = 0)
    val result = snackbarHostState.showSnackbar(
    message = "Scrolled to the top!",
    actionLabel = "Revert",
    )
    if (result == SnackbarResult.ActionPerformed) {
    listState.animateScrollToItem(firstItemIndex, firstItemOffset)
    }
    }
    }
    )
    Coroutines
    val firstItemIndex = listState.firstVisibleItemIndex
    val firstItemOffset = listState.firstVisibleItemScrollOffset

    View Slide

  75. Coroutines

    View Slide

  76. Resources
    ● Compose API guidelines
    ● Kotlin for Jetpack Compose
    ● Understanding Compose (ADS '19)
    ● Extension oriented design
    ● Suspending over Views by Chris Banes
    NEW!
    goo.gle/compose-api-guidelines
    goo.gle/kotlin-for-compose
    goo.gle/understanding-compose
    goo.gle/extension-oriented-design
    goo.gle/suspending-over-views

    View Slide

  77. Thank You!
    Márton Braun
    @zsmb13
    Composing an API with Kotlin

    View Slide