최근 Flutter 프로젝트에서 홈 화면 위젯을 개발할 기회가 있었습니다. 네이티브 개발 경험이 많지 않은 상태에서 위젯을 만들다 보니 여러 시행착오를 겪었습니다. 특히 Glance의 Text
Composable의 제한적인 스타일링 기능과, 사용자가 위젯 크기를 조절할 때 자연스럽게 UI가 반응하도록 만드는 부분이 가장 까다로웠습니다.
이 글은 저처럼 위젯을 만들면서 비슷한 어려움을 겪는 분들께 도움이 되고자 작성했습니다. 제가 문제를 해결하며 찾은 방법들, 특히 커스텀 폰트 적용 및 세밀한 텍스트 스타일링과 다양한 위젯 크기에 대응하는 반응형 레이아웃 구현을 중심으로 실용적인 팁들을 공유하고자 합니다.
완성된 위젯 미리보기
이번 글에서 만들 뉴스 위젯의 모습입니다. 뉴스 데이터는 공공데이터포털의 국제방송교류재단_뉴스기사API를 이용하였습니다. 사용자가 위젯의 크기를 조절하면, 위젯 내부의 콘텐츠도 그에 맞춰 동적으로 변경됩니다. 아래는 크기를 다르게 조절했을 때의 예시입니다.
Flutter와 네이티브 위젯 연동: home_widget
Flutter 앱에서 안드로이드 홈 위젯과 데이터를 주고받기 위해 home_widget이라는 Flutter 플러그인을 사용했습니다. 이 패키지는 Flutter와 네이티브 위젯 간의 통신 다리 역할을 해주어, Flutter 코드에서 위젯의 데이터를 업데이트하거나 위젯의 액션에 반응할 수 있게 해줍니다. 더 자세한 사용법은 공식 문서에서 확인하실 수 있습니다.
home_widget
은 데이터 통신을 담당할 뿐, 위젯의 UI는 각 플랫폼의 네이티브 코드로 직접 만들어야 합니다. 안드로이드에서는 Jetpack Glance를 사용해 위젯 UI를 구현했습니다. 이제부터 Glance로 위젯을 개발하는 과정을 본격적으로 살펴보겠습니다.
1. Glance로 위젯 개발 시작하기
위젯 개발을 위해 여러 방법을 찾아보던 중, Jetpack Compose와 유사한 선언형 방식으로 UI를 만들 수 있는 Glance를 사용하기로 했습니다. Compose에 익숙하다면 훨씬 쉽게 적응할 수 있다는 장점이 컸습니다.
Glance의 핵심 구성 요소
Glance로 위젯을 만들려면 두 가지 핵심 클래스를 알아야 합니다.
GlanceAppWidget
: 위젯의 UI 콘텐츠를 정의하는 곳입니다.@Composable
함수를 사용해 UI를 구성합니다.GlanceAppWidgetReceiver
: 위젯의 업데이트 요청 등 시스템 브로드캐스트를 수신하는AppWidgetProvider
입니다.
기존 XML 방식보다 좋은 점
오랫동안 안드로이드 위젯은 XML 레이아웃과 RemoteViews
를 조합해서 만들어야 했습니다. 이 방식은 몇 가지 불편한 점이 있었죠.
- 절차적인 업데이트:
RemoteViews
의setTextViewText
,setImageViewResource
같은 메서드를 일일이 호출하여 UI를 업데이트해야 했습니다. 코드가 길어지고 직관적이지 않았습니다. - 상태 관리의 어려움: 위젯의 상태가 변경될 때마다 UI를 수동으로 동기화하는 로직을 직접 관리해야 했습니다.
- 제한적인 개발 환경: UI 변경 사항을 확인하려면 매번 위젯을 다시 설치해야 해서 개발 속도가 더뎠습니다.
Glance는 이런 문제들을 해결합니다.
- 선언형 UI: "상태가 이러면, UI는 이렇다"라고 선언만 하면 되므로 코드가 간결하고 이해하기 쉽습니다.
- 익숙한 Compose 문법: Jetpack Compose를 안다면 거의 모든 지식을 그대로 활용할 수 있습니다.
- 편리한 상태 관리:
GlanceStateDefinition
으로 위젯의 상태를 쉽게 저장하고 불러올 수 있습니다. - 미리보기 지원: Android Studio에서 UI를 미리 볼 수 있어 개발 속도가 훨씬 빨라집니다.
Jetpack Compose와는 어떻게 다를까요?
Glance는 Compose와 비슷하지만, 위젯이라는 특수한 환경 때문에 몇 가지 차이가 있습니다.
- 목적: Glance는 앱 UI 전체가 아닌 홈 화면 위젯 제작에 특화되어 있습니다.
- API 제한:
Row
,Column
,Text
등 기본적인 Composable만 사용할 수 있고, 복잡한 애니메이션이나 커스텀 드로잉은 지원되지 않습니다. - 실행 환경: Glance 코드는 UI를 직접 그리지 않고,
RemoteViews
객체를 만드는 역할을 합니다. 실제 UI는 홈 화면 런처 프로세스에서 그려집니다.
2. 기본 레이아웃 구성하기
Glance의 레이아웃 구성은 Jetpack Compose와 거의 동일합니다. Column
을 사용해 위젯을 세로로 쌓고, Row
로 가로로 배열하며, Text
와 Image
로 콘텐츠를 표시합니다. Composable 함수에 GlanceModifier
를 적용하여 패딩, 정렬, 배경 등 다양한 속성을 설정할 수 있습니다.
@Composable
fun MyWidgetContent() {
Column(
modifier = GlanceModifier.fillMaxSize().padding(16.dp).background(Color.White),
verticalAlignment = Alignment.Vertical.CenterVertically,
horizontalAlignment = Alignment.Horizontal.CenterHorizontally
) {
Text("안녕하세요, Glance!")
Row(verticalAlignment = Alignment.Vertical.CenterVertically) {
Image(
provider = ImageProvider(R.drawable.ic_launcher_foreground),
contentDescription = "icon",
modifier = GlanceModifier.size(24.dp)
)
Text("위젯입니다.")
}
}
}
3. 심화 팁: UI 커스터마이징
Glance로 기본 레이아웃을 구성하고 보니 바로 첫 번째 문제에 부딪혔습니다. 디자인 시안에 맞춰 폰트를 적용하고 자간과 행간을 세밀하게 조절해야 했는데, Glance의 기본 Text
Composable은 이를 지원하지 않았습니다. 이 문제를 해결하기 위해 찾은 방법은 텍스트를 직접 비트맵으로 그려서 Image
로 표시하는 것이었습니다.
폰트, 자간(letter-spacing), 행간(line-spacing) 커스터마이징
TextPaint
와 StaticLayout
을 사용하면 원하는 폰트, 색상, 자간, 행간을 모두 적용한 텍스트를 비트맵으로 만들 수 있습니다. 코드는 조금 복잡해지지만, 디자인 제약에서 완전히 벗어날 수 있다는 큰 장점이 있습니다.
아래는 관련 기능을 모아둔 유틸리티 함수 예시입니다.
// MultiLineTextAsBitmap.kt
fun multiLineTextAsBitmap(
context: Context,
text: String,
width: Int,
textSize: Float,
textColor: Int = Color.BLACK,
fontResId: Int,
lineSpacingMultiplier: Float = 1.0f,
letterSpacing: Float = 0f,
maxLines: Int = Int.MAX_VALUE,
// ...
): Bitmap {
val textPaint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply {
this.color = textColor
this.textSize = textSize * context.resources.displayMetrics.density
this.typeface = ResourcesCompat.getFont(context, fontResId)
this.letterSpacing = letterSpacing
}
val staticLayout =
StaticLayout.Builder.obtain(text, 0, text.length, textPaint, width)
.setLineSpacing(0f, lineSpacingMultiplier)
// ...
.build()
val bitmap = createBitmap(staticLayout.width, staticLayout.height)
val canvas = Canvas(bitmap)
staticLayout.draw(canvas)
return bitmap
}
이미지에 그라데이션 추가하기
이미지 위에 텍스트를 표시할 때 배경 때문에 글자가 잘 안 보인다면, 이미지 위에 어두운 그라데이션을 깔아 가독성을 높일 수 있습니다. 이 역시 비트맵을 직접 다루어 구현합니다.
// BitmapUtils.kt
fun createBitmapWithGradient(
originalBitmap: Bitmap,
colors: IntArray,
positions: FloatArray?
): Bitmap {
val resultBitmap = createBitmap(originalBitmap.width, originalBitmap.height)
val canvas = Canvas(resultBitmap)
canvas.drawBitmap(originalBitmap, 0f, 0f, null)
val gradient = LinearGradient(
0f, 0f, 0f, originalBitmap.height.toFloat(),
colors, positions, Shader.TileMode.CLAMP
)
val paint = Paint().apply { shader = gradient }
canvas.drawRect(0f, 0f, originalBitmap.width.toFloat(), originalBitmap.height.toFloat(), paint)
return resultBitmap
}
4. 크기에 따라 변하는 반응형 위젯 만들기
두 번째 큰 문제는 사용자가 위젯 크기를 조절할 때 그에 맞춰 콘텐츠를 자연스럽게 보여주는 것이었습니다. 위젯이 너무 작아지면 글자가 깨지거나 잘리고, 너무 커지면 공간이 비어 보이는 문제를 해결해야 했습니다.
1단계: 위젯 크기 조절 옵션 켜기
먼저 위젯의 크기 조절이 가능하다고 시스템에 알려줘야 합니다. appwidget-provider
XML 파일에서 android:resizeMode
속성을 설정합니다. 이번 예시에서는 위젯을 수평, 수직으로 모두 조절할 수 있도록 설정합니다.
horizontal
: 수평 조절만 허용vertical
: 수직 조절만 허용horizontal|vertical
: 양방향 조절 허용
<!-- news_widget_info.xml -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
...
android:resizeMode="horizontal|vertical" />
2단계: 위젯 크기 변경 감지하기
GlanceAppWidget
클래스에서 sizeMode
속성을 오버라이드하여 위젯 크기를 어떻게 처리할지 정합니다. 반응형 UI를 만들려면 SizeMode.Exact
를 사용하는 것이 좋습니다. 크기가 바뀔 때마다 Content()
함수가 다시 호출되어 UI를 새로 그릴 수 있습니다.
// NewsWidget.kt
class NewsWidget : GlanceAppWidget() {
override val sizeMode: SizeMode = SizeMode.Exact
// ...
}
3단계: 정확한 위젯 크기 가져오고 UI 구성 결정하기
Content()
함수 안에서 LocalSize.current
를 쓰면 현재 크기를 알 수 있지만, 위젯이 처음 생길 때 등 일부 상황에서는 정확하지 않을 수 있습니다. 가장 확실한 방법은 AppWidgetManager
를 통해 런처가 알려주는 크기 정보를 직접 가져오는 것입니다.
// NewsWidget.kt
fun getWidgetSizeInfo(context: Context, glanceId: GlanceId): DpSize? {
if (glanceId !is AppWidgetId) return null
val appWidgetManager = AppWidgetManager.getInstance(context)
val options = appWidgetManager.getAppWidgetOptions(glanceId.appWidgetId)
val maxWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 0).dp
val maxHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0).dp
return if (maxWidth > 0.dp && maxHeight > 0.dp) DpSize(width = maxWidth, height = maxHeight) else null
}
위젯 크기 계산 로직 흐름도
위젯의 정확한 크기를 가져와 UI 설정을 계산하는 과정은 여러 단계를 거칩니다. 특히 위젯이 처음 생성될 때는 시스템이 크기 정보를 즉시 제공하지 않을 수 있어, 약간의 지연과 재시도 로직이 필요합니다. 이 부분이 처음에는 가장 까다롭게 느껴질 수 있습니다.
이렇게 얻은 크기 정보와 위젯의 타입(cellType
) 등을 calculateWidgetUiConfig
함수에 전달하여 현재 상태에 가장 적합한 UI 설정을 계산합니다. 이 함수는 위젯의 너비와 높이에 따라 동적으로 폰트 크기, 아이템 개수, 제목 줄 수 등을 계산하여 WidgetUiConfig
객체를 반환합니다.
// config/WidgetUiConfigCalculator.kt
@Composable
fun calculateWidgetUiConfig(
isPreviewMode: Boolean,
actualSize: DpSize,
fontScale: Float
): WidgetUiConfig {
val widgetActualHeightDp = if (isPreviewMode) {
REFERENCE_HEIGHT_2_CELL_DP
} else {
actualSize.height
}
val widgetActualWidthDp = actualSize.width
val referenceWidthForScale = REFERENCE_WIDTH_2_CELL_DP
val scaleFactor: Float = widgetActualWidthDp.value / referenceWidthForScale.value
val titleFontSizeSp: TextUnit = (14f * scaleFactor / fontScale).sp
// 이 레이아웃에서는 아이템은 1개, 제목은 최대 3줄로 설정합니다.
val numItemsToDisplay = 1
val itemTitleLineLimit = 3
val totalVerticalPaddingDp = (WIDGET_HORIZONTAL_PADDING_FACTOR * 2 * scaleFactor).dp
val logoHeightDp = (LOGO_MAX_HEIGHT_SMALL_DP * scaleFactor).dp
val headerPaddingBottomDp = (WIDGET_HEADER_PADDING_BOTTOM_FACTOR * scaleFactor).dp
val headerHeightDp = logoHeightDp + headerPaddingBottomDp
val newsListAreaHeightDp = (widgetActualHeightDp - totalVerticalPaddingDp - headerHeightDp).coerceAtLeast(0.dp)
val itemSpacingDp = (NEWS_ITEM_SPACING_FACTOR * scaleFactor).dp
val totalSpacingHeightDp = 0.dp // 아이템이 하나이므로 간격은 0입니다.
val availableHeightForItems = (newsListAreaHeightDp - totalSpacingHeightDp).coerceAtLeast(0.dp)
val maxItemImageSizeDp = (availableHeightForItems / numItemsToDisplay).coerceAtLeast(30.dp)
return WidgetUiConfig(
scaleFactor,
titleFontSizeSp,
itemTitleLineLimit,
numItemsToDisplay,
widgetActualHeightDp,
widgetActualWidthDp,
maxItemImageSizeDp,
logoHeightDp,
headerPaddingBottomDp,
fontScale
)
}
4단계: 크기에 맞춰 UI 동적으로 렌더링하기
이제 LaunchedEffect
로 위젯 크기를 가져온 뒤, calculateWidgetUiConfig
함수를 호출하여 UI 설정을 얻고 그에 맞는 UI를 보여주면 됩니다. 실제 렌더링 로직은 GlanceContent
와 같은 별도의 Composable 함수에서 관리하는 것이 좋습니다.
// NewsWidget.kt
@Composable
internal fun GlanceContent(cellType: Int, currentState: HomeWidgetGlanceState, isPreviewMode: Boolean = false, glanceId: GlanceId) {
// ... 위젯 크기 가져오는 로직 ...
val actualSize = getWidgetSizeInfo(context, glanceId)
if (isLoadingSize || actualSize == null) {
LoadingWidget()
} else {
val uiConfig = calculateWidgetUiConfig(isPreviewMode, actualSize, fontScale)
// ...
// 위젯 UI를 렌더링합니다.
NewsWidgetItemLayout(
context = context,
newsItem = itemsToActuallyDisplay.firstOrNull(),
uiConfig = uiConfig
)
}
}
마무리하며
이번 글에서는 Flutter 프로젝트의 일부로 안드로이드 위젯을 만들면서 겪었던 문제들과 그 해결 과정을 공유해 보았습니다. 특히 Glance의 기본 기능을 넘어서는 UI 커스터마이징과 반응형 레이아웃 구현에 초점을 맞췄습니다. Glance 덕분에 선언적이고 직관적인 코드로 위젯을 만들 수 있었지만, 실무적인 요구사항을 만족시키기 위해서는 약간의 추가적인 노력이 필요했습니다.
다음 포스트에서는 위젯의 상태를 관리하고 사용자와 상호작용하며 데이터를 주기적으로 업데이트하는 방법, 그리고 다양한 크기의 위젯 레이아웃을 추가하는 방법에 대해서도 더 깊이 다뤄보겠습니다.
전체 코드 보기
이번 글에서 작성한 전체 코드는 아래 GitHub Gist에서 확인하실 수 있습니다.
'Flutter' 카테고리의 다른 글
임베디드 리눅스를 위한 Flutter, flutter-elinux에 대해서 (2) | 2025.07.23 |
---|---|
[Flutter로 개인 블로그 만들기] #3-1. go_router를 이용하여 라우팅 관리하기 (2) | 2024.10.13 |
[Flutter로 개인 블로그 만들기] #2. Github Actions를 이용하여 CI/CD 구현하기 (1) | 2024.09.19 |
[Flutter로 개인 블로그 만들기] #1. Flutter Web을 Github Pages에 업로드하기 (5) | 2024.09.18 |