Glance로 UI 빌드

이 페이지에서는 Glance를 통해 크기를 처리하고 유연하고 반응형 레이아웃을 제공하는 방법을 설명합니다.

Box, Column, Row 사용

Glance에는 세 가지 기본 컴포저블 레이아웃이 있습니다.

  • Box: 요소를 다른 요소 위에 배치합니다. RelativeLayout로 변환됩니다.

  • Column: 세로축에 요소를 차례로 배치합니다. 세로 방향의 LinearLayout로 변환됩니다.

  • Row: 가로축에 요소를 차례로 배치합니다. 가로 방향의 LinearLayout로 변환됩니다.

열, 행, 상자 레이아웃의 이미지
그림 1. 열, 행, 상자가 있는 레이아웃의 예

이러한 각 컴포저블을 사용하면 수정자를 사용하여 콘텐츠의 세로 및 가로 정렬과 너비, 높이, 두께 또는 패딩 제약 조건을 정의할 수 있습니다. 또한 각 하위 요소는 수정자를 정의하여 상위 요소 내부의 공간과 배치를 변경할 수 있습니다.

다음 예에서는 그림 1과 같이 하위 요소를 가로로 균일하게 배포하는 Row를 만드는 방법을 보여줍니다.

Row(modifier = GlanceModifier.fillMaxWidth().padding(16.dp)) {
    val modifier = GlanceModifier.defaultWeight()
    Text("first", modifier)
    Text("second", modifier)
    Text("third", modifier)
}

Row는 사용 가능한 최대 너비를 채우고, 각 하위 요소는 동일한 가중치를 가지므로 사용 가능한 공간을 균등하게 공유합니다. 다양한 두께, 크기, 패딩 또는 정렬을 정의하여 필요에 따라 레이아웃을 조정할 수 있습니다.

스크롤 가능한 레이아웃 사용

반응형 콘텐츠를 제공하는 또 다른 방법은 스크롤 가능하게 만드는 것입니다. LazyColumn 컴포저블을 사용하면 가능합니다. 이 컴포저블을 사용하면 앱 위젯의 스크롤 가능한 컨테이너 내에 표시할 항목 집합을 정의할 수 있습니다.

다음 스니펫은 LazyColumn 내의 항목을 정의하는 다양한 방법을 보여줍니다.

항목 수를 제공할 수 있습니다.

// Remember to import Glance Composables
// import androidx.glance.appwidget.layout.LazyColumn

LazyColumn {
    items(10) { index: Int ->
        Text(
            text = "Item $index",
            modifier = GlanceModifier.fillMaxWidth()
        )
    }
}

개별 항목을 제공합니다.

LazyColumn {
    item {
        Text("First Item")
    }
    item {
        Text("Second Item")
    }
}

다음과 같이 항목의 목록이나 배열을 제공합니다.

LazyColumn {
    items(peopleNameList) { name ->
        Text(name)
    }
}

앞의 예시를 조합하여 사용할 수도 있습니다.

LazyColumn {
    item {
        Text("Names:")
    }
    items(peopleNameList) { name ->
        Text(name)
    }

    // or in case you need the index:
    itemsIndexed(peopleNameList) { index, person ->
        Text("$person at index $index")
    }
}

이전 스니펫은 itemId를 지정하지 않습니다. itemId를 지정하면 성능을 개선하고 Android 12부터 목록과 appWidget 업데이트를 통한 스크롤 위치를 유지하는 데 도움이 됩니다 (예: 목록에서 항목을 추가하거나 삭제할 때). 다음 예는 itemId를 지정하는 방법을 보여줍니다.

items(items = peopleList, key = { person -> person.id }) { person ->
    Text(person.name)
}

SizeMode 정의

AppWidget 크기는 기기, 사용자 선택 또는 런처에 따라 다를 수 있으므로 유연한 위젯 레이아웃 제공 페이지에 설명된 대로 유연한 레이아웃을 제공하는 것이 중요합니다. Glance는 SizeMode 정의와 LocalSize 값을 사용하여 이를 단순화합니다. 다음 섹션에서는 세 가지 모드를 설명합니다.

SizeMode.Single

SizeMode.Single가 기본 모드입니다. 한 가지 유형의 콘텐츠만 제공되었음을 나타냅니다. 즉, 사용 가능한 AppWidget 크기가 변경되더라도 콘텐츠 크기는 변경되지 않습니다.

class MyAppWidget : GlanceAppWidget() {

    override val sizeMode = SizeMode.Single

    override suspend fun provideGlance(context: Context, id: GlanceId) {
        // ...

        provideContent {
            MyContent()
        }
    }

    @Composable
    private fun MyContent() {
        // Size will be the minimum size or resizable
        // size defined in the App Widget metadata
        val size = LocalSize.current
        // ...
    }
}

이 모드를 사용할 때는 다음 사항을 확인하세요.

  • 최소 및 최대 크기 메타데이터 값은 콘텐츠 크기에 따라 올바르게 정의됩니다.
  • 콘텐츠가 예상 크기 범위 내에서 충분히 유연합니다.

일반적으로 다음과 같은 경우에 이 모드를 사용해야 합니다.

a) AppWidget의 크기가 고정되어 있거나 b) 크기를 조절해도 콘텐츠를 변경하지 않는 경우

SizeMode.Responsive

이 모드는 반응형 레이아웃을 제공하는 것과 동일합니다. 즉, GlanceAppWidget에서 특정 크기로 제한된 반응형 레이아웃 집합을 정의할 수 있습니다. 정의된 크기마다 콘텐츠가 생성되고 AppWidget가 생성되거나 업데이트될 때 특정 크기에 매핑됩니다. 그러면 시스템에서 사용 가능한 크기를 기준으로 가장 적합한 크기를 선택합니다.

예를 들어 대상 AppWidget에서 세 가지 크기와 콘텐츠를 정의할 수 있습니다.

class MyAppWidget : GlanceAppWidget() {

    companion object {
        private val SMALL_SQUARE = DpSize(100.dp, 100.dp)
        private val HORIZONTAL_RECTANGLE = DpSize(250.dp, 100.dp)
        private val BIG_SQUARE = DpSize(250.dp, 250.dp)
    }

    override val sizeMode = SizeMode.Responsive(
        setOf(
            SMALL_SQUARE,
            HORIZONTAL_RECTANGLE,
            BIG_SQUARE
        )
    )

    override suspend fun provideGlance(context: Context, id: GlanceId) {
        // ...

        provideContent {
            MyContent()
        }
    }

    @Composable
    private fun MyContent() {
        // Size will be one of the sizes defined above.
        val size = LocalSize.current
        Column {
            if (size.height >= BIG_SQUARE.height) {
                Text(text = "Where to?", modifier = GlanceModifier.padding(12.dp))
            }
            Row(horizontalAlignment = Alignment.CenterHorizontally) {
                Button()
                Button()
                if (size.width >= HORIZONTAL_RECTANGLE.width) {
                    Button("School")
                }
            }
            if (size.height >= BIG_SQUARE.height) {
                Text(text = "provided by X")
            }
        }
    }
}

이전 예에서 provideContent 메서드는 세 번 호출되어 정의된 크기에 매핑됩니다.

  • 첫 번째 호출에서 크기는 100x100로 평가됩니다. 콘텐츠에는 추가 버튼과 상단 및 하단 텍스트가 포함되지 않습니다.
  • 두 번째 호출에서 크기는 250x100로 평가됩니다. 콘텐츠에는 추가 버튼이 포함되지만 상단 및 하단 텍스트는 포함되지 않습니다.
  • 세 번째 호출에서는 크기가 250x250로 평가됩니다. 콘텐츠에는 추가 버튼과 두 텍스트가 모두 포함됩니다.

SizeMode.Responsive는 다른 두 모드의 조합이며, 이 기능을 사용하면 사전 정의된 경계 내에서 반응형 콘텐츠를 정의할 수 있습니다. 일반적으로 이 모드는 성능이 더 뛰어나고 AppWidget의 크기를 조절할 때 더 부드러운 전환이 가능합니다.

다음 표는 사용 가능한 SizeModeAppWidget 크기에 따른 크기 값을 보여줍니다.

사용 가능한 크기 105 x 110 203 x 112 72 x 72 203 x 150
SizeMode.Single 110 x 110 110 x 110 110 x 110 110 x 110
SizeMode.Exact 105 x 110 203 x 112 72 x 72 203 x 150
SizeMode.Responsive 80 x 100 80 x 100 80 x 100 150 x 120
* 정확한 값은 데모 전용입니다.

SizeMode.Exact

SizeMode.Exact는 사용 가능한 AppWidget 크기가 변경될 때마다 (예: 사용자가 홈 화면에서 AppWidget의 크기를 조절할 때) GlanceAppWidget 콘텐츠를 요청하는 정확한 레이아웃을 제공하는 것과 동일합니다.

예를 들어 대상 위젯에서 사용 가능한 너비가 특정 값보다 크면 추가 버튼을 추가할 수 있습니다.

class MyAppWidget : GlanceAppWidget() {

    override val sizeMode = SizeMode.Exact

    override suspend fun provideGlance(context: Context, id: GlanceId) {
        // ...

        provideContent {
            MyContent()
        }
    }

    @Composable
    private fun MyContent() {
        // Size will be the size of the AppWidget
        val size = LocalSize.current
        Column {
            Text(text = "Where to?", modifier = GlanceModifier.padding(12.dp))
            Row(horizontalAlignment = Alignment.CenterHorizontally) {
                Button()
                Button()
                if (size.width > 250.dp) {
                    Button("School")
                }
            }
        }
    }
}

이 모드는 다른 모드보다 더 많은 유연성을 제공하지만 몇 가지 주의사항이 있습니다.

  • AppWidget는 크기가 변경될 때마다 완전히 다시 만들어야 합니다. 이로 인해 성능 문제가 발생할 수 있으며 콘텐츠가 복잡할 때 UI가 점프할 수 있습니다.
  • 사용 가능한 크기는 런처의 구현에 따라 다를 수 있습니다. 예를 들어 런처가 크기 목록을 제공하지 않으면 가능한 최소 크기가 사용됩니다.
  • Android 12 이전 기기에서는 크기 계산 로직이 모든 상황에서 작동하지 않을 수 있습니다.

일반적으로 SizeMode.Responsive를 사용할 수 없는 경우(즉, 반응형 레이아웃의 일부를 실행할 수 없는 경우) 이 모드를 사용해야 합니다.

리소스 액세스

다음 예와 같이 LocalContext.current를 사용하여 Android 리소스에 액세스합니다.

LocalContext.current.getString(R.string.glance_title)

최종 RemoteViews 객체의 크기를 줄이고 동적 색상과 같은 동적 리소스를 사용 설정하려면 리소스 ID를 직접 제공하는 것이 좋습니다.

컴포저블과 메서드는 ImageProvider와 같은 '제공자'를 사용하거나 GlanceModifier.background(R.color.blue)과 같은 오버로드 메서드를 사용하는 리소스를 허용합니다. 예:

Column(
    modifier = GlanceModifier.background(R.color.default_widget_background)
) { /**...*/ }

Image(
    provider = ImageProvider(R.drawable.ic_logo),
    contentDescription = "My image",
)

복합 버튼 추가

복합 버튼은 Android 12에 도입되었습니다. Glance는 다음과 같은 복합 버튼 유형의 이전 버전과의 호환성을 지원합니다.

이러한 복합 버튼에는 각각 '선택됨' 상태를 나타내는 클릭 가능한 뷰가 표시됩니다.

var isApplesChecked by remember { mutableStateOf(false) }
var isEnabledSwitched by remember { mutableStateOf(false) }
var isRadioChecked by remember { mutableStateOf(0) }

CheckBox(
    checked = isApplesChecked,
    onCheckedChange = { isApplesChecked = !isApplesChecked },
    text = "Apples"
)

Switch(
    checked = isEnabledSwitched,
    onCheckedChange = { isEnabledSwitched = !isEnabledSwitched },
    text = "Enabled"
)

RadioButton(
    checked = isRadioChecked == 1,
    onClick = { isRadioChecked = 1 },
    text = "Checked"
)

상태가 변경되면 제공된 람다가 트리거됩니다. 다음 예와 같이 확인 상태를 저장할 수 있습니다.

class MyAppWidget : GlanceAppWidget() {

    override suspend fun provideGlance(context: Context, id: GlanceId) {
        val myRepository = MyRepository.getInstance()

        provideContent {
            val scope = rememberCoroutineScope()

            val saveApple: (Boolean) -> Unit =
                { scope.launch { myRepository.saveApple(it) } }
            MyContent(saveApple)
        }
    }

    @Composable
    private fun MyContent(saveApple: (Boolean) -> Unit) {

        var isAppleChecked by remember { mutableStateOf(false) }

        Button(
            text = "Save",
            onClick = { saveApple(isAppleChecked) }
        )
    }
}

CheckBox, Switch, RadioButtoncolors 속성을 제공하여 색상을 맞춤설정할 수도 있습니다.

CheckBox(
    // ...
    colors = CheckboxDefaults.colors(
        checkedColor = ColorProvider(day = colorAccentDay, night = colorAccentNight),
        uncheckedColor = ColorProvider(day = Color.DarkGray, night = Color.LightGray)
    ),
    checked = isChecked,
    onCheckedChange = { isChecked = !isChecked }
)

Switch(
    // ...
    colors = SwitchDefaults.colors(
        checkedThumbColor = ColorProvider(day = Color.Red, night = Color.Cyan),
        uncheckedThumbColor = ColorProvider(day = Color.Green, night = Color.Magenta),
        checkedTrackColor = ColorProvider(day = Color.Blue, night = Color.Yellow),
        uncheckedTrackColor = ColorProvider(day = Color.Magenta, night = Color.Green)
    ),
    checked = isChecked,
    onCheckedChange = { isChecked = !isChecked },
    text = "Enabled"
)

RadioButton(
    // ...
    colors = RadioButtonDefaults.colors(
        checkedColor = ColorProvider(day = Color.Cyan, night = Color.Yellow),
        uncheckedColor = ColorProvider(day = Color.Red, night = Color.Blue)
    ),

)