ABOUT ME

Today
Yesterday
Total
  • Android Compose - Composable Component API Guideline
    Android 2024. 2. 29. 20:40

    다음의 글을 안드로이드 개발자 입장에서 생각하면서 번역했습니다. 본문과 다를 수 있습니다.

     

    API Guidelines for components in Jetpack Compose

    API Guidelines for @Composable components in Jetpack Compose Last updated: July 19, 2023 Set of guidelines and recommendations for building scalable and user-friendly @Composable components. The requirement level of each of these guidelines is specified us

    android.googlesource.com


    Before you create a component

    새로운 컴포넌트 생성 시 고려사항

    • 각 컴포넌트는 단 하나의 문제를 해결해야 함, 여러 문제를 해결하는 컴포넌트는 더 작은 '서브 컴포넌트'나 'building block'으로 분할되어야 함.
    • 컴포넌트 개발 전, 실제로 필요한지 그리고 장기적인 지원과 API 발전에 정당화한 가치를 갖는지 고려해야 함.
      때로는 라이브러리를 사용하는 개발자가 직접 컴포넌트를 작성하는 것이 더 효율적일 수 있음

    Component's purpose

    위 '새로운 컴포넌트 생성 시 고려사항'과 같이 컴포넌트는 하나의 문제만 해결하고, 그 곳에서 해결돼야 됩니다. 만약, 컴포넌트가 하나 이상의 문제를 해결한다면, 해당 컴포넌트를 하위 계층이나 하위 컴포넌트로 분할할 수 있는지 고려해야 합니다.

     

    하위 수준의 'building block'과 '서브 컴포넌트'는 간단하고 특정한 단일 기능을 가지며, 다른 컴포넌트와 쉽게 결합될 수 있도록 설계됩니다. 예를 들어, Text, Image, TextField 등이 여기에 해당됩니다.

    이런 요소들은 자체적으로 복잡한 기능을 수행하지 않지만, 서로 결합되어 더 크고 복잡한 UI 요소를 구성할 수 있습니다. 각각의 'building block'이 간단하고 명확한 기능을 가지므로, 이를 조합하여 새로운 기능을 만드는 것이 용이합니다.

     

    상위 수준의 컴포넌트는 하위 수준의 'building block'이나 '서브 컴포넌트'를 결합하여 더 큰 기능을 제공합니다. 예를 들어, 로그인 폼, 사용자 프로필 카드, 채팅 메시지 리스트 등이 있을 수 있습니다. 상위 컴포넌트는 특정한 사용 사례나 기능에 맞춰져 있으며, 여러 하위 컴포넌트를 결합하여 '사용할 준비가 된 상태'를 제공합니다. 로그인 폼은 TextField(이메일 및 비밀번호 입력), Button(로그인 버튼), Text(Regex 오류) 등 여러 하위 컴포넌트를 결합하여 만들 수 있습니다.

     

    Don't

    @Composable
    fun Button(
        onClick: () -> Unit = { },
        checked: Boolean = false,
        onCheckedChange: (Boolean) -> Unit
    )

     

    Do

    @Composable
    fun Button(
        onClick: () -> Unit
    )
    
    @Composable
    fun ToggleButton(
        checked: Boolean,
        onCheckedChange: (Boolean) -> Unit
    )

    Component layering

    컴포넌트 생성 시, 컴포넌트가 작동하는 데 필요한 'single purpose building block'을 먼저 제공해야 합니다. 또한 하위 수준에서 상위 수준으로 이동함에 따라 구체적인 패턴을 제시하고, 커스터마이징 할 수 있는 옵션을 줄여야 합니다.

     

    하위 수준의 컴포넌트는 개발자가 많은 부분을 자유롭게 조정할 수 있으며, 기본적이고 범용적인 기능을 제공하여 다양한 상황에 맞춰서 사용할 수 있도록 합니다. 반면, 상위 수준의 컴포넌트는 특정한 사용 사례나 목적에 맞게 미리 설정된 옵션과 스타일을 가지고 있습니다. 이는 개발자가 더 빠르고 쉽게 특정 기능을 구현할 수 있지만 커스터마이징 할 수 있는 옵션은 적습니다.

     

    @Composable은 Compsoe에서 쉽게 생성될 수 있도록 설계 되었으므로, 개발자가 단일 목적 컴포넌트를 생성하고 필요에 따라 조정할 수 있습니다.

     

    Do

    // single purpose building blocks component
    @Composable
    fun Checkbox(...) { ... }
    
    @Composable
    fun Text(...) { ... }
    
    @Composable
    fun Row(...) { ... }
    
    // high level component
    @Composable
    fun CheckboxRow() {
        Row {
            Checkbox(...)
            Text(...)
        }
    }

    Do you need a component?

    컴포넌트를 생성하기 전 실제로 필요한지, 아래와 같은 고민을 해야 합니다.

    • 상위 컴포넌트 생성 전, 실제로 해결할 문제가 있는지 또는 기존에 있는 'building block'으로 해결 가능한 지?
      • 단순한 기능 추가로 인한 새로운 컴포넌트 생성은 비효율적
    • 상위 컴포넌트 생성 전, 'basic building block'으로 컴포넌트로 구현 가능한 지?
      • 구현이 가능하다면, 이는 더 큰 유연성을 가지며, 불필요한 복잡성을 피할 수 있음
    • 컴포넌트를 직접 구현하는 것보다, 상위 컴포넌트를 생성하여 사용하는 것이 더 나은 가치를 제공하는지?
      • 컴포넌트 생성 시, 해당 컴포넌트의 구현을 배워야만 하는 부담이 있을 수 있음

    예를 들어, RadioGroup 컴포넌트를 만들 때, 다양한 레이아웃과 데이터 유형을 지원해야 하기 위해, 다음과 같이 설계할 수 있습니다.

    @Composable 
    fun <T> RadioGroup(
        options: List<T>,
        orientation: Orientation,
        contentPadding: PaddingValues,
        modifier: Modifier = Modifier,
        optionContent: @Composable (T) -> Unit
    )

     

    RadioGroup 컴포넌트를 'basic building block'을 사용하여 다음과 같이 작성할 수 있습니다.

    Column(
        modifier = Modifier.selectableGroup()
    ) {
        options.forEach { item -> 
            Row(
                modifier = Modifier.selectable(
                    selected = (select.value == item),
                    onClick = { select.value = item }
                ),
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(text = item.toString())
                RadioButton(
                    selected = (select.value == item),
                    onClick = { select.value = item }
                )
            }
        }
    }

     

    위 예시와 같이 Row, Text, RadioButton 등의 'basic building block'을 사용하여 직접 구현할 수 있습니다. 이런 구현은 필요한 레이아웃을 정의하거나 커스터마이징이 가능한 유연성을 얻을 수 있습니다. 이로 인해, RadioGroup을 도입하는 것이 좋지 않은 선택일 수 있습니다.

     

    이처럼, 새로운 컴포넌트 생성은 개발, 테스팅, 장기적인 지원 및 API 업데이트 등 대한 비용이 많이 발생할 수 있기에, 새로운 컴포넌트를 만드는 것이 실제로 필요한지, 그리고 그 가치가 이런 비용을 정당화할 수 있는지 고려해야 합니다.

    Component or Modifier

    다른 컴포넌트에 적용할 수 없는 독특한 UI 또는 UI의 구조적 변경('add•remove other component')이 필요한 경우에만 컴포넌트를 만들어야 합니다.

     

    그 외, 임의의 단일 컴포넌트에 동작이나 기능을 추가해야 하는 경우, Modifier를 사용해야 합니다. 이는, 특정 기능을 동시에 여러 UI 컴포넌트에 적용할 때 예상치 못한 동작을 할 수 있으므로, Modifier를 사용하는 것이 적절합니다.

     

    Don't

    @Composable
    fun Padding(allSides: Dp) { 
        // Impl 
    }
    
    Padding(12.dp) {
        UserCard()
        UserPicture()
    }

     

    Do

    fun Modifier.padding(allSides: Dp): Modifier = // implementation
    
    UserCard(modifier = Modifier.padding(12.dp))

     

    특정 기능이 어떤 'Composable'에도 적용이 가능하지만, 'Composable' 계층 구조를 변경해야 하는 경우(enter•leave Composition)
    Modifier는 계층 구조를 변경할 수 없으므로, 컴포넌트를 사용해야 합니다.

     

    Do

    @Composable
    fun AnimatedVisiblity(
        visible: Boolean,
        modifier: Modifier = Modifier,
        content: @Composable () -> Unit
    ) {
        // implementation
    }
    
    // usage
    AnimatedVisiblity(visible = false) { 
        UserCard()
    }

    Name of a Component

    BasicComponent vs Component

    'Basic' 접두사를 가진 컴포넌트는 가장 간단한 형태로 구현되어 제공합니다. 즉, 복잡한 스타일이나 추가적인 기능을 포함하지 않습니다. 이는 개발자가 'BasicComponent'에 스타일링이나 기능을 추가하여 커스터마이징 할 수 있음을 의미합니다.

     

    이와 대조적으로 접두사가 없는 컴포넌트는 어떠한 디자인 시스템이나 스타일링을 가지고 있으며, 바로 사용할 준비가 된 컴포넌트를 나타냅니다. 즉, 추가적인 커스타마이징 없이도 바로 사용될 수 있으며, 더 복잡하고 구체적인 디자인 가이드라인이 적용된 컴포넌트입니다.

     

    Do

    @Composable
    fun BasicTextField(
        value: TextFieldValue,
        onValueChange: (TextFieldValue) -> Unit,
        modifier: Modifier = Modifier,
        ...
    )
    
    @Composable
    fun TextField(
        value: TextFieldValue,
        onValueChange: (TextFieldValue) -> Unit,
        modifier: Modifier = Modifier,
        ...
    )

    Design, Usecase or Company/Project specific prefixes

    컴포넌트 이름에 회사 이름(GoogleButton)이나 모듈 이름(WearButton)과 같은 접두사를 사용하는 것을 피하는 것이 좋습니다. 이는 컴포넌트의 범용성을 제한하고, 특정 회사나 모듈에만 국한된 느낌을 줄 수 있습니다. 필요한 경우, 컴포넌트가 수행하는 'UseCase 또는 Domain'을 반영하는 이름을 사용하는 것이 좋습니다.

     

    만약, 'compose-foundation'이나 'compose-ui'와 같은 'basic building block' 컴포넌트를 기반으로 구축하는 경우, 대부분의 비접두사 이름(Button, Icon 등)은 개발자에게 충돌 없이 제공될 수 있습니다. 이러한 이름은 컴포넌트가 프로젝트 내에서 중요하고 기본적인 요소로 인식될 수 있도록 합니다. ('first-class'처럼 느끼기 해줌)

     

    'building block' 컴포넌트를 래핑 하거나, 'Material'과 같이 다른 디자인 시스템을 기반으로 구축하는 경우에 ScalingLazyColumn, CurvedText과 같이, 'Usecase'에서 파생된 이름을 사용하는 것을 권장합니다.

    만약, 'Usecase' 기반 이름 사용이 불가능하거나, 새로운 컴포넌트가 기존 컴포넌트와 충돌하는 경우, GlideImage와 같이 '모듈/라이브러리' 접두사를 사용할 수 있습니다.

     

    특정 디자인 시스템이 유사한 컴포넌트를 제공하지만, 다양한 스타일을 갖는 경우에 ContainedButton, OutlinedButton, SuggestionChip와 같이, 'specification' 접두사를 사용하여 '스타일' 패턴을 피하고 API를 단순하게 유지할 수 있습니다.

     

    만약 여러 컴포넌트가 있고 각각 다른 접두사를 가지고 있다면, 그중에서 가장 일반적으로 사용될 것으로 예상되는 컴포넌트를 선택하여 기본 컴포넌트로 선택하여 접두사 없이 사용하는 것이 좋습니다. 예를 들어, Button, OutlinedButton, TextButton과 같은 컴포넌트가 있다면, Button을 접두사 없이 사용하고, 나머지 컴포넌트는 접두사를 사용하는 것이 좋습니다.

     

    Do

    // ContainedButton.kt
    
    // `ContainedButton`으로 명시 되었지만, 가장 일반적인으로 사용되는 버튼이므로 접두사 없이 사용
    @Composable
    fun Button(...)
    
    @Composable
    fun OutlineButton(...)
    
    @Composable
    fun TextButton(...)
    
    @Composable
    fun GlideImage(...)

     

    'compose-foundation' 기반 라이브러리

    // Package com.company.project
    // 해당 컴포넌트들은 material, material3 의존 하지 않고, foundation 라이브러리 기반 구축
    
    @Composable
    fun Button(...) // 기본 버튼
    
    @Composable
    fun TextField(...) // 기본 텍스트 필드

    Component dependencies

    Prefer multiple components over style classes

    ComponentStyle 이나 ComponentConfiguration과 같은 일반적인 스타일 파라미터나 클래스 사용을 피하고, 의존성을 세밀하고 의미 있게 표현해야 합니다.

     

    '동일 타입 서브 컴포넌트 집합'이 동일한 구성이나 스타일을 가져야 할 때, 개발자는 컴포넌트를 래핑 하거나 'lower-level building block'을 사용하여 맞춤형 컴포넌트를 만드는 것이 좋습니다. 이렇게 하면 각 컴포넌트가 특정한 목적이나 스타일에 맞춰 조정될 수 있습니다.

     

    ComponentStyle을 사용하여 다양한 컴포넌트 타입을 지정하는 대신, 스타일링과 usecase에서의 차이를 나타내는 별도의 @Composable 함수를 제공하는 것이 좋습니다.

     

    Don't

    class ButtonStyles(
        background: Color,
        border: BorderStroke,
        textColor: Color,
        shape: Shape,
        contentPadding: PaddingValues
    )
    
    val PrimaryButtonStyle = ButtonStyle(...)
    val SecondaryButtonStyle = ButtonStyle(...)
    val AdditiveButtonStyle = ButtonStyle(...)
    
    @Composable
    fun Button(
        onClick: () -> Unit,
        style: ButtonStyle = PrimaryButtonStyle
    ) {
        // implementation
    }
    
    // usage
    val myLoginStyle = ButtonStyle(...)
    Button(onClick = { /*...*/ }, style = myLoginStyle)

     

    Do

    @Composable
    fun PrimaryButton(
        onClick: () -> Unit,
        background: Color,
        border: BorderStroke,
        // other relevant parameters
    ) {
        // impl
    }
    
    @Composable
    fun SecondaryButton(
        onClick: () -> Unit,
        background: Color,
        border: BorderStroke,
        // other relevant parameters
    ) {
        // impl
    }
    
    // usage 1:
    PrimaryButton(
        onClick = { /*...*/ },
        background = Color.Blue,
        border = BorderStroke(1.dp, Color.Black)
    )
    
    // usage 2:
    @Composable
    fun MyLoginButton(
        onClick: () -> Unit
    ) {
        SecondaryButton(
            onClick = onClick,
            background = Color.Red,
            border = BorderStroke(1.dp, Color.Black)
        )
    }

    Explicit vs Implicit dependencies

    컴포넌트에서는 'explicit input'과 'configuration options'을 선호해야 합니다.

    컴포넌트의 'explicit input'은 컴포넌트의 동작을 예측하고, 조정하고, 테스트하고, 사용하기 쉽게 만듭니다.

     

    CompositionLocal 또는 다른 유사한 메커니즘을 통해 제공되는 'implicit input'을 피해야 합니다.

    'implicit input'은 컴포넌트의 사용성을 복잡하게 하고, 개발자가 커스터마이징의 출처를 추적하기 어렵게 만듭니다. 'implicit input'을 피하기 위해서는, 개발자가 원하는 'explicit input'의 부분 집합으로 커스터마이징 컴포넌트를 쉽게 만들 수 있게 해야 합니다.

     

    Don't

    // 특정 컴포넌트 커스터마이징을 위한 CompositionLocal 피하기
    // CompositionLocal은 암시적이기에, 컴포넌트의 변경, 테스트, 사용을 어렵게 함
    val LocalButtonBorder = compositionLocalOf<BorderStroke>(...)
    
    @Composable
    fun Button(
        onClick: () -> Unit,
    ) {
        val border = LocalButtonBorder.current
    }

     

    Do

    @Composable
    fun Button(
        onClick: () -> Unit,
        // explicit parameter, default value를 가짐 
        border: BorderStroke = ButtonDefaults.borderStroke,
    ) {
        // impl
    }

     

    MaterialTheme, Typography, ColorScheme 등과 같이 Application, Screen 전체에 일관되게 적용해야 하는 경우, CompositionLocal을 사용하여 암시적으로 제공할 수 있습니다. 이를 수행할 때, CompositionLocal이 컴포넌트 파라미터의 기본 값으로만 사용되고, 개발자가 필요에 따라 쉽게 재정의 할 수 있도록 해야 합니다.


    이는 개별 컴포넌트의 유연성과 전체 앱의 일관성을 유지하는데 도움이 됩니다. 따라서 컴포넌트는 자체 구현에서 직접적으로 CompositionLocal을 읽어오기보다는, 파라미터 기본 값에서 읽어오는 것이 좋습니다.

     

    Don't

    class Theme(val mainAppColor: Color)
    val LocalAppTheme = compositionLocalOf { Theme(Color.Green) }
    
    @Composable
    fun Button(
        onClick: () -> Unit,
    ) {
        val buttonColor = LocalAppTheme.current.mainAppColor
        // use theme
    }

     

    Do

    class Theme(val mainAppColor: Color)
    val LocalAppTheme = compositionLocalOf { Theme(Color.Green) }
    
    @Composable
    fun Button(
        onClick: () -> Unit,
        backgroundColor: Color = LocalAppTheme.current.mainAppColor
    ) {
        // use theme
    }

    Component parameters

    Parameters vs Modifier on the component

    파라미터는 컴포넌트의 기본적인 동작이나 특징을 설정하는데 중점을 두어야 합니다. 반면, Modifier는 스타일링 또는 레이아웃을 조정하는 데 사용되어야 합니다.

     

    Don't

    @Composable
    fun Image(
        bitmap: ImageBitmap,
        // 핵심 기능이 아닐 경우, `Modifier.clickble`을 통해 추가 가능 
        onClick: () -> Unit = { },
        modifier: Modifier = Modifier,
        // `Modifire.clip(CircleShape)`을 통해 지정 가능
        clipToCircle: Boolean = false
    )

     

    Do

    @Composable
    fun Button(
        onClick: () -> Unit,
        modifier: Modifier = Modifier,
        backgroundColor: Color = MaterialTheme.colors.primary
    )

    modifier parameter

    UI를 생성하는 컴포넌트는 모두 Modifier 파라미터를 가져야 하며, 다음을 준수하여야 합니다.

    • Modifier 타입을 가져야 합니다.
      • 컴포넌트가 다양한 Modifier를 수용할 수 있도록 하여, 컴포넌트 스타일링과 동작을 유연하게 할 수 있습니다.
    • 첫 번째 'optional parameter' 이어야 합니다.
      • 컴포넌트가 기본 크기를 가진 경우, modifier를 선택 파라미터로 받을 수 있습니다.
      • 컴포넌트의 기본 크기가 0인 경우, modifier를 필수 파라미터로 받아야 합니다.
      • modifier는 모든 컴포넌트에 권장되어 자주 사용되므로, 이를 첫 번째로 두면 명명된 파라미터 없이 설정할 수 있으며, 모든 컴포넌트에서 일관성 있게 사용할 수 있습니다.
    • no-op(연산 없음) Modifier를 기본 값으로 가집니다.
      • modifier 파라미터가 제공되지 않았을 때 컴포넌트의 기본 동작이나 외형에 영향을 주지 않도록 합니다.
    • Modifier 타입의 파라미터가 하나만 있어야 합니다.
      • Modifier는 컴포넌트의 외형이나 동작을 수정하는 데 사용되므로, 하나의 Modifier 만으로 충분합니다.
      • 여러 Modifier가 필요하다고 판단된 경우, 컴포넌트의 설계를 다시 하는 것이 좋습니다. (예 : 컴포넌트를 두 개로 분할)
    • Modifier는 컴포넌트의 가장 루트에 가까운 레이아웃에 첫 번째로 한 번 적용되어야 합니다.
      • Modifier가 체인의 첫 번째로 적용되면, 이를 기반으로 모든 후속 modifier를 적용합니다.
      • 파라미터로 전달된 modifier에 다른 modifier를 체인으로 연결할 수 있습니다.

     

    Why?

     

    Compose를 사용하는 개발자들은 Modifier를 통해 컴포넌트의 크기, 위치, 패딩, 클릭 이벤트 등을 쉽게 조절할 수 있음을 알고 있습니다. 본질적으로 파라미터로 입력되는 modifier는 외부 컴포넌트의 동작과 외형을 수정하는 방법을 제공하는 반면, 컴포넌트 구현에서는 내부 동작과 외형을 책임집니다.

     

    Don't

    // modifier parameter 없음
    @Composable
    fun Icon(
        bitmap: ImageBitmap,
        tint: Color = Color.Black,
    )
    
    // 첫 번째 modifier optional parameter가 아님
    // 개발자가 modifier를 설정하는 즉시 padding이 손실됨
    @Composable
    fun Icon(
        bitmap: ImageBitmap,
        tint: Color = Color.Black,
        modifier: Modifier = Modifier.padding(8.dp),
    )
    
    // 아래 modifier들은 CheckboxRow 외부 동작을 지정하기 위해 의도된게 아닌, 
    // 하위 부분을 수정하는 데 사용됨
    @Composable
    fun CheckboxRow(
        checked: Boolean,
        onChckedChange: (Boolean) -> Unit,
        rowModifier: Modifier = Modifier,
        checkboxModifier: Modifier = Modifier,
    )
    
    // modifier는 가장 가까운 루트 레이아웃에 첫 번째로 적용해야 하고,
    // 이를 체인의 첫 번째 요소로 사용해야 함
    @Composable
    fun IconButton(
        buttonBitmap: ImageBitmap,
        modifier: Modifier = Modifier,
        tint: Color = Color.Black
    ) {
        Box(
            modifier = Modifier.padding(16.dp)
        ) {
            Icon(
                bitmap = buttonBitmap,
                tint = tint,
                modifier = Modifier
                    .aspectRatio(1f)
                    .then(modifier)
            )
        }
    }

     

    Do

    @Composable
    fun IconButton(
        buttonBitmap: ImageBitmap,
        modifier: Modifier = Modifier,
        tint: Color = Color.Black
    ) {
        Box(
            modifier = modifier.padding(16.dp)
        ) {
            Icon(
                bitmap = buttonBitmap,
                tint = tint,
                modifier = Modifier.aspectRatio(1f)
            )
        }
    }
    
    @Composable
    fun ColoredCanvas(
        modifier: Modifier,
        color: Color = Color.White,
    ) {
        Box(
            modifier = modifier.background(color)
        ) {
            // ...
        }
    }

    Parameters order

    컴포넌트의 파라미터 순서는 다음과 같아야 합니다.

    1. Required parameters.
    2. Single modifier: Modifier = Modifier.
    3. Optional parameters.
    4. (optional) trailing @Composable lambda.

    Why?

     

    'required parameter'들은 컴포넌트의 핵심 기능을 위해 반드시 필요하므로, 개발자에게 해당 파라미터들을 제공해야만 컴포넌트가 제대로 동작함을 알리기 위해 가장 먼저 배치합니다.

     

    'modifier parameter'는 UI 요소의 레이아웃, 스타일링, 동작을 조정하는 데 사용됩니다.

    이는 컴포넌트의 외관과 동작에 중요한 영향을 미치므로 'first optional parameter'로 배치합니다.

     

    'optional parameter'들은 컴포넌트의 추가 기능이나 커스터마이징을 위해 사용됩니다.

    개발자는 이 파라미터들을 필수적으로 제공할 필요는 없지만, 필요에 따라 재정의 할 수 있습니다. 이들은 'required parameter'와 'modifier parameter' 다음에 배치함으로써, 개발자가 컴포넌트의 기본 동작에 먼저 집중하고, 필요에 따라 추가 설정을 할 수 있도록 합니다.

     

    '@Composable lambda'는 컴포넌트의 콘텐츠를 나타내며, 'content'로 명명됩니다.

    개발자에게 컴포넌트 안에 자신의 UI 콘텐츠를 제공할 수 있게 해주는 옵션입니다.

    LazyColumn과 같은 DSL 형식 컴포넌트에서는 @Composable이 아닌 람다를 사용하는 것이 허용됩니다.


    'required' 및 'optional' 파라미터 그룹 내에서, 컴포넌트의 'What' 데이터 우선순위가 있을 것입니다. 이런 우선순위를 고려하여 파라미터 그룹 내에서 순서를 정리하고, 그다음 메타 데이터, 커스텀마이징 등의 'How' 파라미터를 배치하는 것이 좋습니다.

     

    또한, 'required' 및 'optional' 파라미터 그룹 내에서 파라미터를 의미적으로 그룹화하는 것이 좋습니다. 예를 들어, 여러 색상 파라미터('backgroundColor', 'contentColor')가 있다면, 커스터마이징 옵션을 쉽게 볼 수 있도록 이들을 함께 배치하는 것이 좋습니다.

     

    Do

    @Composable
    fun Icon(
        // 아이콘 표현을 위한 필수 데이터, 가장 먼저 위치 
        bitmap: ImageBitmap,
        // 접근성을 위한 메타 데이터, 필수 데이터 다음에 위치
        contentDescription: String?,
        // modifier param = first optional parameter
        modifier: Modifier = Modifier,
        // optional param, Theme or CompositionLocal에서 제공되는 기본 값
        tint: Color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
    )
    
    @Composable
    fun LazyColumn(
        // 'required param'이 없기에, 'modifier param'이 첫 번째로 위치 
        modifier: Modifier = Modifier,
        // list의 상태를 나타내며, '데이터'로 분류되기에 두 번째로 위치
        state: LazyListState = rememberLazyListState(),
        contentPadding: PaddingValues = PaddingValues(0.dp),
        reverseLayout: Boolean = false,
        // arrangement 및 alignment는 연관되어 있기에 함께 위치 
        verticalArrangement: Arrangement.Vertical = 
          if(!reverseLayout) Arrangement.Top else Arrangement.Bottom,
        horizontalAlignment: Alignment.Horizontal = Alignment.Start,
        flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
        userScrollEnabled: Boolean = true,
        // 'trailing lambda' 콘텐츠, 마지막에 위치
        content: LazyListScope.() -> Unit
    )

    Nullable parameter

    'nullable parameter' 도입 시, 파라미터가 가질 수 있는 다양한 상태(default, empty, absent)를 명확히 구분하고, 이들이 각각 어떤 의미로 전달되는지를 이해하는 것이 중요합니다.

     

    'Nullability parameter'는 개발자에게 해당 파라미터가 'absence' 할 수 있음을 표현할 수 있습니다. 이는 파라미터가 선택적일 수 있음을 의미하며, 이를 통해 유연성을 제공할 수 있음을 의미합니다. 그러나, 이는 잠재적인 'null' 관련 오류 가능성을 증가시킬 수 있으므로 신중하게 사용되어야 합니다.

     

    'default value'를 구현하라는 신호로 null을 활용하는 'nullable parameter'를 만들면 안 됩니다. 이는 컴포넌트의 사용을 복잡하게 만들고, 예기치 않은 오류를 발생시킬 수 있습니다.

     

    값이 존재하지만 비어 있다는 신호로 'nullable parameter'를 만들면 안됩니다. 대신에, 명확하게 'empty' 상태를 나타내는 'default value'를 사용하는 것이 좋습니다. 예를 들어, 문자열 파라미터의 경우, 'empty' 상태를 나타내는 ""를 사용하는 것이 좋습니다.

     

    Don't

    @Composable
    fun IconCard(
        bitmap: ImageBitmap,
        elevation: Dp? = null,
    ) {
        val resolvedElevation = elevation ?: DefaultElevation
    }

     

    Do

    @Composable
    fun IconCard(
        bitmap: ImageBitmap,
        elevation: Dp = 8.dp,
    ) {
        // ...
    }

     

    Or Do (null is meaningful here)

    @Composable
    fun IconCard(
        bitmap: ImageBitmap,
        // null일 경우, contentDescription을 제공하지 않음을 의미
        contentDescription: String?,
    ) {
        // ...
    }

    Default expressions

    개발자들은 'optional parameter'에 대한 기본 표현식(default expressions)을 설정할 때 다음 관행을 따르는 것이 좋습니다.

     

    기본 표현식은 private/internal 호출을 포함해서는 안됩니다. 이는 다른 개발자들이 해당 컴포넌트를 확장하거나 래핑 할 때 동일한 '기본 값'을 제공할 수 있게 합니다. 또는 개발자가 if문에서 해당 '기본 값'을 사용할 수 있습니다. : if (condition) default else myUserValue

     

    기본 표현식은 의미가 있고 명확해야 합니다. 내부적으로 값의 'absence'를 나타내는 경우 null을 사용해야 하며, '기본 값'을 사용하기 위한 '마커'로 null을 사용하는 것은 피해야 합니다.

     

    여러 기본 표현식이 있는 경우, ComponentDefaults 객체를 사용하여 '네임스페이스'를 제공하는 것이 좋습니다.

     

    Don't

    @Composable
    fun IconCard(
        bitmap: ImageBitmap,
        backgroundColor: Color = DefaultBackgroundColor,
        // null을 기본값으로 사용하여 '마커'로 사용하는 것을 피해야 함
        elevation: Dp? = null
    ) {
        val resolvedElevation = elevation ?: DefaultElevation
    }
    
    // 컴포넌트 래핑 시, 접근 할 수 없어 코드를 이해하기 어렵고, 수정하기 어려움 (private)
    private val DefaultBackgroundColor = Color.White
    private val DefaultElevation = 8.dp

     

    Do

    @Composable
    fun IconCard(
        bitmap: ImageBitmap,
        backgroundColor: Color = IconCardDefaults.BackgroundColor,
        elevation: Dp = IconCardDefaults.Elevation
    ) {
        // ...
    }
    
    object IconCardDefaults {
        val BackgroundColor = Color.White
        val Elevation = 8.dp
    }

     

    컴포넌트 파라미터가 기본값이 짧고 예측 가능한 경우(elevation = 0.dp), ComponentDefaults 객체를 생략하고 간단한 인라인 상수를 사용할 수 있습니다.

    MutableState as a parameter

    MutableState<T> 타입의 파라미터 사용은 컴포넌트와 호출자 간의 상태에 대한 공동 소유권을 유도하므로, 권장되지 않습니다.
    가능하다면 'stateless 컴포넌트'로 만들고 호출자에게 상태 변경을 위임하는 것이 좋습니다. 만약, 컴포넌트에서 호출자가 소유한 속성의 변경이 필요한 경우, MutableState<T>가 아닌, ComponentState 클래스를 만들어 사용하는 것이 좋습니다.

     

    컴포넌트가 MutableState<T>를 파라미터로 받으면 상태를 변경하는 능력을 얻게 됩니다. 이는 상태 소유권이 분리되어 있어, 상태를 소유하고 있던 호출자는 컴포넌트 구현 내에서 언제 어떻게 변경될지 제어할 수 없게 됩니다.

     

    Don't

    @Composable
    fun Scroller(
        offset: MutableState<Float>
    ) { ... }

     

    Do (stateless version, if possible)

    @Composable
    fun Scroller(
        offset: Float,
        onOffsetChange: (Float) -> Unit
    ) { ... }

     

    Or do (state-based component version, if stateless not possible)

    class ScrollerState {
        val offset: Float by mutableStateOf(0f)
    }
    
    @Composable
    fun Scroller(
        state: ScrollerState
    ) { ... }

    State as a parameter

    State<T> 타입 파라미터 사용은 컴포넌트에 전달되는 객체 타입을 불필요하게 제한하기에 권장되지 않습니다. 이에 따라 param: State<Float> 대신, 2가지 대안이 있습니다.

    • param: Float : 파라미터가 자주 변경되지 않거나 컴포넌트에서 즉시 읽는 경우, 단순하게 파라미터를 제공하고, 변경 시 컴포넌트를 recompose 합니다.
    • param: () -> Float : 나중에 param.invoke()를 통해 값을 읽을 수 있도록 람다를 파라미터로 제공합니다. 이는 컴포넌트의 개발자가 필요할 때만 값을 읽을 수 있으므로, 불필요한 작업을 피할 수 있습니다. 예를 들어, 그리기 작업 중에만 값을 읽는다면, 그리기 작업 중에만 다시 그리기가 발생합니다. 이는 개발자에게 State<T>의 값을 읽는 표현식을 제공할 수 있는 유연성을 남겨 줍니다.
      • param = { myState.value } : State<T>의 값을 읽음
      • param = { justValueWithoutState } : State<T>를 사용하지 않는 단순 값
      • param = { myObject.offset } : mutableStateOf()로 지원되는 커스텀 상태 객체

    Don't

    fun Badge(position: State<Dp>) {}
    Badge(position = scrollState.offset)

     

    Do

    val myState = mutableStateOf(10f)
    Badge(position = { myState.value })
    
    // or
    
    val justValueWithoutState = 15f
    Badge(position = { justValueWithoutState })
    
    // or
    
    val state = rememberListState()
    Badge(position = { state.offset })
    
    /// impl
    
    fun Badge(position: () -> Float) {
      val currentPosition = position()
      // ...
    }

    Slot parameters

    What are slots

    슬롯은 컴포넌트의 특정 하위 계층을 지정하는 @Composable의 람다 파라미터를 말합니다.

     

    예를 들어, Button의 'content slot'은 다음과 같습니다.

    @Composable
    fun Button(
        onClick: () -> Unit,
        content: @Composable () -> Unit
    ) { ... }
    
    // usage
    Button(onClick = { /*...*/ }) {
        Text("Button")
    }

     

    이러한 패턴은 Button 컴포넌트가 내부의 'content'에 대해 특정 요구사항이나 스타일을 가지지 않으며, 기본적인 외관을 제공하고, 'click'과 'ripple'을 처리하도록 합니다. 즉, 'Slot pattern'은 컴포넌트가 자신의 주요 기능을 유지하면서, 그 내용을 유연하게 변경할 수 있게 해 주며, 맞춤형 UI 컴포넌트를 쉽게 만들 수 있게 해 줍니다.

    Why slots

    TextIcon이 포함된 Button 컴포넌트 작성 시, 다음과 같이 작성하는 것이 솔깃할 수 있지만, 이는 권장되지 않습니다.

     

    Don't

    @Composable
    fun Button(
        onClick: () -> Unit,
        text: String? = null,
        icon: ImageBitmap? = null,
    )

     

    이 경우, Buttontexticon을 받아, 둘 다 사용하거나, 둘 다 사용하지 않거나, 둘 중 하나만을 사용할 수 있습니다. 이런 방식은 기본적인 사용 사례에서는 잘 동작하지만, 아래와 같은 몇 가지 근본적인 유연성 문제가 있습니다.

     

    스타일링 선택 제한 : ButtonString만 받기 때문에, AnnotatedString이나 다른 텍스트 정보를 사용할 수 없습니다. 이를 위해 ButtonTextStyle 파라미터를 추가해야 하며, 이는 컴포넌트를 복잡하게 만듭니다.

     

    컴포넌트 선택 제한 : Button에서 MyTextWithLogging()를 통해 '로깅 이벤트' 같은 추가 로직을 수행하고 싶을 수 있습니다. 여기서 String만을 받게 되면 개발자는 Button을 수정해야 하는 상황에 직면하게 됩니다.

     

    오버로드 폭발 : 컴포넌트 유연성을 위해, ButtonImageBitmapVectorPainter를 모두 받을 수 있도록 하려면, 이를 위한 '오버로드가 추가'로 필요합니다. 이는 text 파라미터도 String, AnnotatedString, CharSequence 등 여러 유형을 지원하는 경우에도 동일합니다.

     

    컴포넌트 레이아웃 기능 제한 : Button은 텍스트와 아이콘을 표시할 수 있지만, 텍스트와 아이콘의 '배치'는 Button이 결정합니다.
    이는 곧, 텍스트와 아이콘 사이에 패딩을 추가하는 등의 커스터마이징이 불가능함을 의미합니다.


    슬롯을 가진 컴포넌트들은 위 문제들로부터 자유로워 슬롯에 어떤 컴포넌트와 어떤 스타일링도 넣을 수 있게 됩니다. 이는 컴포넌트의 유연성을 높이고, 컴포넌트의 사용성을 높이며, 컴포넌트의 재사용성을 높일 수 있습니다.

     

    Do

    @Composable
    fun Button(
        onClick: () -> Unit,
        text: @Composable () -> Unit,
        icon: @Composable () -> Unit,
    )

    Single 'content' slot overloads

    여러 슬롯을 가지는 컴포넌트의 경우, 'content'로 명명된 'single slot' 오버로드를 제공하는 것이 좋습니다. 이는 레이아웃 로직을 변경할 수 있기에, 사용 측면에서 더 많은 유연성을 제공합니다.

     

    Do

    @Composable
    fun Button(
        onClick: () -> Unit,
        content: @Composable () -> Unit,
    ) { ... }
    
    // usage
    Button(onClick = { /*...*/ }) {
        Row {
            Icon(...)
            Text(...)
        }
    }

    Layout strategy scope for slot APIs

    'single content overload'의 경우, 컴포넌트를 쉽고 효과적으로 사용할 수 있도록 적합한 레이아웃 전략(layout strategy)을 선택하는 것이 중요합니다. 이는 컴포넌트의 사용성과 유연성을 높이는데 도움이 됩니다.

     

    위 예시에서 Button 컴포넌트는 일반적으로 '단일 텍스트', '단일 아이콘', '행에 있는 아이콘과 텍스트', '행에 있는 텍스트와 아이콘' 등으로 사용할 수 있습니다. 이런 사용 패턴을 기반으로, RowScope를 제공하면 Button 컴포넌트의 사용이 좀 더 쉬워집니다.

     

    Do

    @Composable
    fun Button(
        onClick: () -> Unit,
        content: @Composable RowScope.() -> Unit
    ) { ... }
    
    // usage
    Button(onClick = { /*...*/ }) {
        Icon(...)
        Text(...)
    }

     

    컴포넌트에 대한 다른 타입의 레이아웃 전략으로 ColumnScopeBoxScope 등이 있습니다. 컴포넌트의 저자는 슬롯에 여러 컴포넌트가 전달될 때 어떤 일이 발생할지 항상 생각해야 하며, Scope를 통해 이런 행동을 사용자에게 전달하는 것을 고려해야 합니다.

     

    Lifecycle expectations for slot parameters

    슬롯 파라미터로 사용되는 'Composable'은 해당 슬롯을 포함하는 컴포넌트의 생명주기와 동일하고, 슬롯 파라미터의 생명주기는 컴포넌트가 'viewport'에서 가시성을 유지하는 동안 유지되어야 합니다.

     

    슬롯 파라미터로 사용되는 'Composable'은 컴포넌트의 구조적 또는 시각적 변경에 따라 폐기되고 다시 구성되어서는 안 됩니다.

     

    슬롯 컴포저블의 생명주기에 영향을 미치는 내부 구조 변경이 필요한 경우, remember{}movableContentOf()를 사용해야 합니다.

     

    Don't

    @Composable
    fun PreferenceItem(
        checked: Boolean,
        content: @Composable () -> Unit
    ) {
        if (checked) {
            Row {
                Text("Checked")
                content()
            }
        } else {
            Column {
                Text("Unchecked")
                content()
            }
        }
    }

     

    Do

    @Composable
    fun PreferenceItem(
        checked: Boolean,
        content: @Composable () -> Unit
    ) {
        Layout({
            Text("Preference item")
            content()
        }) {
            // checked 상태 변경에 따라 `content` 인스턴스를 다시 레이아웃
        }
    }

     

    Or Do

    @Composable
    fun PreferenceItem(
        checked: Boolean,
        content: @Composable () -> Unit
    ) {
        // `row`와 `column`에서 `content` 생명주기 유지, 불필요한 재구성 방지
        val movableContent = remember(content) { movableContentOf(content) }
    
        if (checked) {
            Row {
                Text("Checked")
                movableContent()
            }
        } else {
            Column {
                Text("Unchecked")
                movableContent()
            }
        }
    }

     

    Do

    @Composable
    fun PreferenceItem(
        checked: Boolean,
        checkedContent: @Composable () -> Unit
    ) {
        // `checkedContent`는 체크된 상태에서만 보이기에, 
        // 해당 슬롯이 존재하지 않을 때 폐기되고, 다시 존재할 때 다시 구성되는 것은 괜찮음
        if (checked) {
            Row {
                Text("Checked")
                checkedContent()
            }
        } else {
            Column {
                Text("Unchecked")
            }
        }
    }

    Component-related classes and functions

    State

    Core design practices with state : 자세한 설명

    ComponentDefault object

    모든 컴포넌트 'default expressions'는 inline or 최상위 객체 ComponentDefaults 안에 있어야 합니다. : 자세한 설명

    ComponentColor/ComponentElevation objects

    간단한 분기 로직을 위해 if-else문을 사용하거나, 특정 'Color/Elevation'이 반영될 수 있는 'input'을 명확하게 정의할 때,
    ComponentColor/ComponentElevation 객체를 사용하는 것이 좋습니다.

     

    컴포넌트 상태(enabled/disabled, focused/hovered/pressed 등)에 따라 특정 단일 타입의 파라미터(Color, Dp 등)를 제공하거나, 정의할 수 있는 여러 방법이 있습니다.

     

    Do (if color choosing logic is simple)

    @Composable
    fun Button(
        onClick: () -> Unit,
        enabled: Boolean = true,
        backgroundColor = 
            if (enabled) ButtonDefaults.enabledBackgroundColor 
            else ButtonDefaults.disabledBackgroundColor,
        elevation =
            if (enabled) ButtonDefaults.enabledElevation 
            else ButtonDefaults.disabledElevation,
        content: @Composable RowScope.() -> Unit
    )

     

    위 방법은 잘 동작하지만, 이런 표현식은 더 크게 증가하여 코드를 복잡하게 만들 수 있습니다.
    그래서, 이를 특정 도메인과 파라미터에 대한 전용 클래스를 따로 만드는 것이 좋을 수 있습니다.

     

    Do (if color conditional logic is more complicated)

    class ButtonColors(
        backgroundColor: Color,
        disabledBackgroundColor: Color,
        contentColor: Color,
        disabledContentColor: Color,
    ) {
        fun backgroundColor(enabled: Boolean): Color { ... }
        fun contentColor(enabled: Boolean): Color { ... }
    }
    
    object ButtonDefaults {
        fun colors(
            backgroundColor: Color = Color.Blue,
            disabledBackgroundColor: Color = Color.Gray,
            contentColor: Color = Color.White,
            disabledContentColor: Color = Color.Black,
        ): ButtonColors {
            return ButtonColors(
                backgroundColor,
                disabledBackgroundColor,
                contentColor,
                disabledContentColor,
            )  
        } 
    }
    
    @Composable
    fun Button(
        onClick: () -> Unit,
        enabled: Boolean = true,
        colors: ButtonColors = ButtonDefaults.colors(),
        content: @Composable RowScope.() -> Unit
    ) {
        val resolvedBackgroundColor = colors.backgroundColor(enabled)
    }

     

    위와 같은 방법은, 'style pattern'의 오버헤드와 복잡성을 도입하지 않으면서, 컴포넌트의 특정 부분에 대한 구성을 분리할 수 있습니다. 또한, 일반적인 'default expressions'와 달리 ComponentColors 또는 ComponentElevation의 더 세밀한 제어가 가능합니다.


    Accessibility of the component

    Modifier.clickable이나 Image와 같은 'basic building block'은 기본적으로 접근성을 고려한 디자인을 제공합니다.
    만약 접근성에 필요한 정보가 누락되었다면, 이를 명시적으로 요청합니다. 반면, Layout이나 Modifier.pointerInput과 같은 UI 수준 블록 사용 시, 자동으로 접근성을 관리하지 않기에 수동으로 처리해 주어야 합니다.

    Semantics merging

    Compose는 접근성을 목적으로 'semantics merging'을 사용합니다. 이를 통해, content 슬롯을 가진 Button은 '접근성 서비스'가 알릴 텍스트를 별도로 설정하지 않아도 됩니다. 대신, content의 'semantics'(Icon의 'contentDescription' 또는 Text의 'text')가 버튼에 병합됩니다.

     

    Modifier.semantics(mergeDescendants = true)는 컴포넌트의 모든 자식 요소들의 'semantics'를 병합하여 하나의 노드로 만들 수 있습니다. 이렇게 함으로써, '접근성 서비스'는 해당 컴포넌트를 단일 엔티티로 처리하게 됩니다. Modifier.clickable이나 Modifier.toggleable과 같은 일부 기본 레이어 'modifier'들은 기본적으로 자식 요소들의 'semantics'를 병합합니다.

    Accessibility related parameters

    ImageIcon과 같은 컴포넌트에서는 개발자로부터 접근성 관련 정보를 받는 것이 중요합니다. 예를 들어, Image 컴포넌트는 contentDescription을 필수 파라미터로 받아, 이미지에 대한 설명을 제공하는 데 사용되고, 스크린 리더와 같은 접근성 도구가 contentDescription를 사용하여 이미지의 내용을 시각 장애가 있는 사람들에게 전달할 수 있습니다.

     

    컴포넌트에 일반 Modifier 파라미터를 제공하여, 개발자가 자신의 컴포넌트에 Modifier.semantics를 사용할 수 있도록 하는 것이 좋습니다. 그러나 만약, 하나의 'modifier chain'에 동일한 'key'를 가진 2개의 SemanticsProperties가 있는 경우, 첫 번째 SemanticsProperties를 우선으로 하고, 이후의 것들을 무시합니다. 따라서 컴포넌트가 필요로 할 수 있는 모든 접근성 관련 기능에 대해 별도의 파라미터를 추가할 필요는 없습니다.

    'Android' 카테고리의 다른 글

    Compose와 Hilt 의존성 주입  (0) 2024.05.08
    Compose - Strong skipping mode  (0) 2024.04.26
    Android Compose - API GuideLine  (0) 2024.02.28
    Android Standby Bucket  (0) 2022.07.01
    Android - Safety Net API (에뮬레이터 감지 및 루팅 감지)  (0) 2022.02.11
Designed by Tistory.