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

글로벌 웹툰 안드로이드 Large Screen 적용기

글로벌 웹툰 안드로이드 Large Screen 적용기

10/30 (목) NAVER ENGINEERING DAY 2025 (10월) 행사에서 발표한 내용입니다.
"글로벌 웹툰 안드로이드 Large Screen 적용기"

발표 내용 요약
- Large Screen을 대응해야 하는 이유와 품질 가이드라인을 소개합니다.
- 글로벌웹툰이 View와 Compose 환경에서 Large Screen 최적화를 적용한 내용을 일부 공유합니다.

Avatar for Sungyong An

Sungyong An

October 28, 2025
Tweet

More Decks by Sungyong An

Other Decks in Programming

Transcript

  1. Contents Large Screen을 적용해야 하는 이유 Large Screen 최적화, 무엇을

    해야 하나? 글로벌웹툰에서 구현한 방법 Next Step
  2. Large Screen을 적용해야 하는 이유 Foldable, Tablet, ChromeOS 등 대형

    화면 폼 팩터 기기 수가 늘어나는 추세. 다가오는 Android 16 동작 변경사항. 그리고 연결된 디스플레이 지원.
  3. Large Screen 적용? 다양한 화면 크기에 반응하여, 최적화된 사용자 경험을

    제공하는 앱 (a.k.a 적응형 앱) Link: h"ps://developer.android.com/develop/ui/compose/build-adaptive-apps
  4. Android 폼 팩터 Foldable, Tablet, Desktop, TV, Car, XR 등

    Large Screen 기기가 늘어나는 추세 (5억 대 이상) Link: h"ps://developer.android.com/develop#devices • Google I/O ’25 "미국의 6대 주요 스트리밍 앱을 조사한 결과, 태블릿과 스마트폰을 함께 사용하는 유저들 은 스마트폰만 사용하는 유저들에 비해 이용 시간이 3배 더 길었다"
  5. Android 폼 팩터: 사용자 수 • Google Play Console >

    모니터링 및 개선 > 도달범위 및 기기 개요 > 폼 팩터 > 살펴보기 > 시간 경과에 따른 사용자와 문제 분포 > 설치한 사용자 수 • 글로벌웹툰의 1년 전과 현재를 비교해보면: • 전화 사용자 수 1.5% 증가 • 태블릿 사용자 수 33% 증가 • 태블릿 사용자 수가 늘고 있다? Google Play Console에서 폼 팩터마다 시간 경과에 따른 사용자 수를 확인할 수 있다
  6. Android 폼 팩터: 사용자 수 • Google Play Console >

    모니터링 및 개선 > 도달범위 및 기기 개요 > 폼 팩터 > 살펴보기 > 시간 경과에 따른 사용자와 문제 분포 > 설치한 사용자 수 • 글로벌웹툰의 1년 전과 현재를 비교해보면: • 전화 사용자 수 1.5% 증가 • 태블릿 사용자 수 33% 증가 • 태블릿 사용자 수가 늘고 있다? • 카운터포인트리서치 “2025년 2분기 전세계 폴더블 스마트폰 시장, 전년 동기 대비 45% 성장” • 폴더블 사용자 수가 늘고 있다 Google Play Console에서 폼 팩터마다 시간 경과에 따른 사용자 수를 확인할 수 있다 Link: h"ps://korea.counterpointresearch.com/global-foldable-sma#phone-market-q2-2025/
  7. Android 폼 팩터: 사용자 이용 시간 • Firebase > Analytics

    Dashboard > 비교 적용 (모바일 트래픽, 태블릿 트래픽) • 활성 사용자당 평균 참여 시간 • 활성 사용자당 참여 세션수 • 세션당 평균 참여 시간 Firebase Analytics에서 태블릿과 스마트폰 사용자 그룹을 나누어 앱 이용 시간을 비교할 수 있다
  8. Android 폼 팩터: 사용자 이용 시간 • Firebase > Analytics

    Dashboard > 비교 적용 (모바일 트래픽, 태블릿 트래픽) • 활성 사용자당 평균 참여 시간 (모바일 우세) • 활성 사용자당 참여 세션수 (모바일 우세) • 세션당 평균 참여 시간 (태블릿 우세) Firebase Analytics에서 태블릿과 스마트폰 사용자 그룹을 나누어 앱 이용 시간을 비교할 수 있다
  9. Android 폼 팩터: 사용자 이용 시간 • Firebase > Analytics

    Dashboard > 비교 적용 • 활성 사용자당 평균 참여 시간 • 활성 사용자당 참여 세션수 • 세션당 평균 참여 시간 • 모바일보다 태블릿에서의 수치가 크게 낮다면 Large Screen 기기에서의 사용성이 떨어지는 것일 수 있다 Firebase Analytics에서 태블릿과 스마트폰 사용자 그룹을 나누어 앱 이용 시간을 비교할 수 있다
  10. Android 16 동작 변경사항: 적응형 레이아웃 방향, 크기 조절 가능

    여부, 가로세로 비율 제한 무시 (최소 너비가 600dp 이상) Link: h"ps://developer.android.com/about/versions/16/behavior-changes-16#adaptive-layouts
  11. Android 16 동작 변경사항: 적응형 레이아웃 다음 매니페스트 속성과 런타임

    API는 전체 화면 및 멀티 윈도우 모드의 대형 화면 기기에서 무시됩니다 • android:resizeableActivity="false" • android:screenOrientation="portrait" • android:minAspectRatio="2" • android:maxAspectRatio="3" • Activity#setRequestedOrientation(int) • Activity#getRequestedOrientation() 방향, 크기 조절 가능 여부, 가로세로 비율 제한 무시 (최소 너비가 600dp 이상) Link: h"ps://developer.android.com/about/versions/16/behavior-changes-16#adaptive-layouts
  12. Large Screen 최적화, 무엇을 해야 하는가? 대형 화면 앱 품질

    가이드라인. Play Store의 순위 및 품질 개선.
  13. 대형 화면 앱 품질 • TIER 3 (기본) - 대형

    화면 지원 • TIER 2 (우수) - 대형 화면 최적화 • TIER 1 (최고) - 대형 화면 차별화 앱이 기기 폼 팩터나 화면 크기, 디스플레이 모드, 상태와 관계없이 우수한 사용자 환경을 제공하려면 대형 화면 호환성 체크리스트 및 테스트를 완료하세요 Link: h"ps://developer.android.com/docs/quality-guidelines/large-screen-app-quality
  14. 대형 화면 앱 품질: TIER 3 • 핵심 앱 품질

    요구사항을 충족해야 합니다 • 앱이 사용 가능한 디스플레이 영역(전체 화면 또는 멀티 윈도우 모드에서는 앱 창)을 채웁니다 앱이 레터박스 처리되지 않으며 호환성 모드로 실행되지 않습니다 • 기기가 분할 화면 및 데스크톱 창 모드에서 기기 회전, 접기 및 펼치기, 창 크기 조절과 같은 구성 변경을 거칠 때 앱이 구성 변경을 처리하고 상태를 유지하거나 복원합니다 • 앱이 멀티 윈도우 모드에서 완전하게 작동합니다 • 앱이 가로 모드 방향과 세로 모드 방향, 접힌 기기 상태와 펼쳐진 기기 상태, 멀티 윈도우 모드에서 카메라 미리보기 및 미디어 프로젝션을 제공합니다 • 키보드, 마우스, 트랙패드, 스타일러스를 지원합니다 사용자가 중요한 작업 흐름을 완료할 수 있지만 최적의 사용자 환경은 제공되지 않습니다 Link: h"ps://developer.android.com/docs/quality-guidelines/large-screen-app-quality
  15. LS-C1: 기기 호환성 모드 Android 12 큰 화면 기기에서 resizeableActivity="false"

    로 설정하면, 레터박스가 적용된다 호환성 모드와 관련된 속성들을 모두 제거해야 한다 Link: h"ps://developer.android.com/guide/practices/device-compatibility-mode
  16. LS-C1: 기기 호환성 모드 screenOrientation 속성을 지정하면 앱 방향이 고정된다

    호환성 모드와 관련된 속성들을 모두 제거해야 한다 Link: h"ps://developer.android.com/guide/practices/device-compatibility-mode
  17. LS-C2: 구성 변경 처리 • UI 상태를 유지하기 위한 옵션

    • AAC ViewModel 사용 • 저장된 인스턴스 상태 • 영구 스토리지 • Activity 재생성 제한 • android:configChanges 추가 • onConfigurationChanged 직접 처리 앱 사용자는 상태가 보존되기를 기대한다 Link: h"ps://developer.android.com/guide/topics/resources/runtime-changes
  18. LS-M2: 멀티 윈도우 모드 지원 • (+ 앞에서 살펴본 항목들)

    • Multi-resume • Window metrics • 지원중단된 Display API 교체 • getSize() • getMetrics() • getRealSize() • getRealMetrics() • WindowManager 또는 WindowMetrics 사용 앱이 멀티 윈도우 모드에서 완전하게 작동해야 한다 Link: h"ps://developer.android.com/develop/ui/compose/layouts/adaptive/suppo#-multi-window-mode
  19. 대형 화면 앱 품질: TIER 2 • 앱에 대형 화면용으로

    설계된 반응형 및 적응형 레이아웃이 있습니다 모든 레이아웃이 반응형입니다 적응형 레이아웃의 구현은 창 크기 클래스에 따라 결정됩니다 • 모달과 옵션 메뉴, 기타 보조 요소는 모든 화면 유형과 기기 상태에서 올바른 형식으로 지정됩니다 (하단 시트/버튼/텍스트 필드의 최대 너비 제한, 탐색 메뉴를 탐색 레일로 대체 등) • 터치 영역은 최소 48dp • 커스텀 드로어블에 포커스 상태를 지원합니다 • 키보드, 마우스, 트랙패드에 관한 고급 기능을 제공합니다 (화살표 키 탐색, 단축키, 미디어 제어, 오른쪽 버튼 클릭, 스크롤, 마우스 오버 상태 지원 등) 앱이 모든 화면 크기 및 기기 구성에 맞게 레이아웃 최적화를 구현하며 외부 입력 장치에 관한 고급 지원을 제공합니다 Link: h"ps://developer.android.com/docs/quality-guidelines/large-screen-app-quality
  20. LS-U1: 반응형 레이아웃, 적응형 레이아웃 • 반응형 레이아웃 사용 가능한

    공간에 맞춰 디자인 요소의 배치를 조정한다 • 적응형 레이아웃 사용 가능한 공간에 맞춰 적절한 레이아웃을 선택한다 Link: h"ps://uxplanet.org/adaptive-vs-responsive-web-design-eead0c2c28a8
  21. LS-U1: 반응형 레이아웃 • ConstraintLayout • wrap_content, match_parent • FlexboxLayout

    Link: h"ps://developer.android.com/develop/ui/views/layout/responsive-adaptive-design-with-views#responsive_design 다양한 화면 크기에 반응하는 하나의 레이아웃을 관리한다
  22. LS-U1: 적응형 레이아웃 • 대체 레이아웃 리소스 res/layout/ res/layout-sw600dp/ res/layout-w600dp/

    Link: h"ps://developer.android.com/develop/ui/views/layout/responsive-adaptive-design-with-views#alternative_layout_resources 다양한 화면 크기에 따라 최적화된 대체 레이아웃을 제공한다
  23. LS-U1: 적응형 레이아웃 • 대체 레이아웃 리소스 • 최소 너비

    한정자 (기기의 현재 방향에 관계없이 동일) res/layout-sw600dp/ • 사용 가능한 너비 한정자 (기기 방향에 따라 변경될 수 있음) res/layout-w600dp/ 다양한 화면 크기에 따라 최적화된 대체 레이아웃을 제공한다 w sw Link: h"ps://developer.android.com/develop/ui/views/layout/responsive-adaptive-design-with-views#alternative_layout_resources
  24. LS-U1: 적응형 레이아웃 • Width/Height Breakpoints • Jetpack WindowManager •

    Jetpack Compose 권장! 창 크기 클래스를 사용하여 레이아웃을 관리할 수 있다 Link: h"ps://developer.android.com/develop/ui/compose/layouts/adaptive/use-window-size-classes
  25. LS-U3: 터치 영역 • 접근성 측면 • 화면 크기에 관계없이

    약 9mm의 실제 크기를 갖는다 • 터치스크린 요소에 권장되는 타겟 크기는 7~10mm • 디자인 가이드에는 터치 영역이 고려되지 않는 경우가 종종 있다 • 개발자가 임의로 영역을 넓혀서 적용한다 터치 영역은 최소 48 x 48 dp로 설정해야 한다 Link: h"ps://material.io/design/usability/accessibility.html#layout-and-typography
  26. LS-U3: 터치 영역 • View TouchDelegate API 터치 영역은 최소

    48 x 48 dp로 설정해야 한다 Link: h"ps://developer.android.com/develop/ui/views/touch-and-input/gestures/viewgroup#delegate val delegateArea = Rect() button.getHitRect(delegateArea) delegateArea.right += 100 delegateArea.bottom += 100 (button.parent as? View)?.touchDelegate = TouchDelegate(delegateArea, button)
  27. LS-U3: 터치 영역 • View TouchDelegate API • Jetpack Compose

    권장! • 블로그 글 "Jetpack Compose: Touch Target" https://medium.com/@fornewid/jetpack-compose-touch-target-6cc2ca2e4b3b 터치 영역은 최소 48 x 48 dp로 설정해야 한다 Link: h"ps://developer.android.com/develop/ui/views/touch-and-input/gestures/viewgroup#delegate
  28. LS-U4: Custom Drawable의 포커스 상태 • 기본 Ripple 효과를 사용하는

    경우에는 자동 적용된다 • Custom Drawable을 사용하는 경우에는? 사용자가 상호작용할 수 있는 요소는 기기가 터치 모드가 아닐 때 포커스 가능해야 한다 <View android:foreground="?selectableItemBackground" /> <style name="MyAppTheme" parent="Theme.AppCompat.NoActionBar"> <item name="android:colorControlHighlight">#66666666</item> <style> Link: h"ps://developer.android.com/training/material/animations#Touch
  29. LS-U4: Custom Drawable의 포커스 상태 <View android:background="@drawable/custom_background" /> <!-- res/drawable/custom_background.xml

    --> <shape android:shape="rectangle"> <solid android:color="@color/custom_background" /> </shape>
  30. LS-U4: Custom Drawable의 포커스 상태 <View android:background="@drawable/custom_background" android:foreground="?selectableItemBackground" /> <!--

    res/drawable/custom_background.xml --> <shape android:shape="rectangle"> <solid android:color="@color/custom_background" /> </shape>
  31. LS-U4: Custom Drawable의 포커스 상태 <View android:background="@drawable/custom_background" android:foreground="?selectableItemBackground" /> <!--

    res/drawable/custom_background.xml --> <shape android:shape="rectangle"> <solid android:color="@color/custom_background" /> <corners android:radius="4dp" /> </shape>
  32. LS-U4: Custom Drawable의 포커스 상태 <View android:background="@drawable/custom_background_with_ripple" /> <!-- res/drawable/custom_background_with_ripple.xml

    --> <ripple android:color="?android:attr/colorControlHighlight"> <item android:drawable="@drawable/custom_background" /> <item android:id="@android:id/mask"> <shape android:shape="rectangle"> <corners android:radius="4dp" /> <solid android:color="#000" /> </shape> </item> </ripple> Link: h"ps://cs.android.com/android/pla$orm/superproject/main/.../drawable/item_background_material.xml
  33. LS-U4: Custom Drawable의 포커스 상태 • Modifier#clickable() 을 사용하는 경우에는

    자동 적용 • Jetpack Compose 권장! • View는 Hover 이펙트를 기본 지원하지 않는다. • ex) 마우스, 스타일러스 등 사용자가 상호작용할 수 있는 요소는 기기가 터치 모드가 아닐 때 포커스 가능해야 한다 Link: h"ps://developer.android.com/develop/ui/compose/touch-input/focus/change-focus-behavior
  34. 대형 화면 앱 품질: TIER 1 • 앱이 다양한 멀티태스킹

    시나리오를 지원합니다 (PIP 모드, 멀티 윈도우 모드, 첨부파일 등) • 앱이 별도의 창에서 여러 자체 인스턴스를 실행할 수 있습니다 • 앱이 모든 폴더블 상태 및 관련 사용 사례를 지원합니다 • 앱이 터치 입력, 마우스, 트랙패드, 스타일러스를 사용하여 앱 내에서 뷰 간에 그리고 멀티 윈도우 모드에서 다른 앱 간에 드래그 앤 드롭을 지원합니다 • 키보드, 마우스, 트랙패드에 관한 고급 기능을 제공합니다 • 앱이 스타일러스로 그리기 및 쓰기를 지원합니다 • 앱이 맞춤설정된 커서를 표시하여 사용자가 UI 요소 및 콘텐츠와 상호작용할 수 있는 방법을 나타냅니다 앱이 태블릿, 폴더블, ChromeOS 기기용으로 설계된 사용자 환경을 제공합니다 Link: h"ps://developer.android.com/docs/quality-guidelines/large-screen-app-quality
  35. Play Store: 순위 및 품질 개선 • Play의 기기별 기술

    품질 기준을 충족하지 못하는 앱과 게임에 대해 앱 목록 경고와 노출 수가 감소합니다 • 이 휴대폰 기술 품질 요건을 대형 화면까지 확대하며, 사용자 기기에서 사용자가 인지하는 비정상 종료율 또는 ANR(응답 지연)율이 8% 이상인 앱과 게임에 적용됩니다 대형 화면 앱 품질 가이드라인을 준수하는 앱과 게임은 검색 및 앱 및 게임 홈에서 더 높은 순위를 차지합니다 Link: h"ps://android-developers.googleblog.com/2023/07/introducing-new-play-store-for-large-screens.html
  36. 최대 너비 제한하기 (View) • 글로벌웹툰은 View로 구현된 화면이 더

    많다 • 최상위 레이아웃은: • 대부분 ConstraintLayout이다 • 또는 FrameLayout 기반이다 (ex. CoordinatorLayout) 기존의 단일 레이아웃을 크게 변경하지 않고 대응할 수 있다
  37. 최대 너비 제한하기 (View) ConstraintLayout에 maxWidth 속성을 추가한다 <FrameLayout> <androidx.constraintlayout.widget.ConstraintLayout

    android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:layout_marginHorizontal="16dp" android:maxWidth="480dp"> ... </androidx.constraintlayout.widget.ConstraintLayout/> </FrameLayout>
  38. 최대 너비 제한하기 (View) ConstraintLayout에 추가한 margin 영역은? <FrameLayout> <androidx.constraintlayout.widget.ConstraintLayout

    android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:layout_marginHorizontal="16dp" android:maxWidth="480dp"> ... </androidx.constraintlayout.widget.ConstraintLayout/> </FrameLayout>
  39. 최대 너비 제한하기 (View) 대신 ConstraintLayout의 Child에 layout_constraintWidth_max 속성을 추가하는

    방법이 있다 <androidx.constraintlayout.widget.ConstraintLayout> <androidx.recyclerview.widget.RecyclerView android:layout_width="0dp" android:layout_height="wrap_content" android:clipToPadding="false" android:paddingHorizontal="16dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintWidth_max="512dp" /> </androidx.constraintlayout.widget.ConstraintLayout/>
  40. 최대 너비 제한하기 (View) 기기 너비에 따라 값을 다르게 적용하고

    싶다면? <androidx.constraintlayout.widget.ConstraintLayout> <androidx.recyclerview.widget.RecyclerView android:layout_width="0dp" android:layout_height="wrap_content" android:clipToPadding="false" android:paddingHorizontal="16dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintWidth_max="512dp" /> </androidx.constraintlayout.widget.ConstraintLayout/> < 600dp
  41. 최대 너비 제한하기 (View) 큰 화면에서는 더 큰 값으로 적용하고

    싶다면? <androidx.constraintlayout.widget.ConstraintLayout> <androidx.recyclerview.widget.RecyclerView android:layout_width="0dp" android:layout_height="wrap_content" android:clipToPadding="false" android:paddingHorizontal="24dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintWidth_max="598dp" /> </androidx.constraintlayout.widget.ConstraintLayout/> >= 600dp
  42. 리소스 한정자 (Resources Qualifier) 최소 너비 한정자를 기준으로 각각 리소스를

    정의한다 res/values/ <dimen name="gwds_layout_contents_max_width">480dp</dimen> <dimen name="gwds_layout_contents_max_width_with_margin">512dp</dimen> <dimen name="gwds_layout_contents_horizontal_margin">16dp</dimen> res/values-sw600dp/ <dimen name="gwds_layout_contents_max_width">550dp</dimen> <dimen name="gwds_layout_contents_max_width_with_margin">598dp</dimen> <dimen name="gwds_layout_contents_horizontal_margin">24dp</dimen> ...
  43. Window Size Classes • Width Breakpoints • Compact: w <

    600dp • Medium: 600dp ≤ w < 840dp • Expanded: 840dp ≤ w • Height Breakpoints • Compact: h < 480dp • Medium: 480dp ≤ h < 900dp • Expanded: h ≥ 900dp • Jetpack Compose 권장! 동적으로 다르게 처리할 때는 창 크기 클래스 중단점을 이용한다 Link: h"ps://developer.android.com/develop/ui/views/layout/use-window-size-classes
  44. Window Size Classes (View) private fun computeWindowSizeClasses() { val metrics

    = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(this) val width = metrics.bounds.width() val height = metrics.bounds.height() val density = resources.displayMetrics.density val windowSizeClass = WindowSizeClass.compute(width/density, height/density) // COMPACT, MEDIUM, or EXPANDED val widthWindowSizeClass = windowSizeClass.windowWidthSizeClass // COMPACT, MEDIUM, or EXPANDED val heightWindowSizeClass = windowSizeClass.windowHeightSizeClass // Use widthWindowSizeClass and heightWindowSizeClass. } Link: h"ps://developer.android.com/develop/ui/compose/layouts/adaptive/use-window-size-classes
  45. Window Size Classes (View) class MainActivity : Activity() { override

    fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val container: ViewGroup = binding.container container.addView(object : View(this) { override fun onConfigurationChanged(newConfig: Configuration?) { super.onConfigurationChanged(newConfig) computeWindowSizeClasses() } }) computeWindowSizeClasses() } } Link: h"ps://developer.android.com/develop/ui/compose/layouts/adaptive/use-window-size-classes
  46. Window Size Classes (Compose) val windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass //

    Compose Material3 Adaptive Link: h"ps://developer.android.com/develop/ui/compose/layouts/adaptive/use-window-size-classes
  47. Window Size Classes (Compose) @Composable fun MyApp( windowSizeClass: WindowSizeClass =

    currentWindowAdaptiveInfo().windowSizeClass ) { val showTopAppBar: Boolean = windowSizeClass.isHeightAtLeastBreakpoint( WindowSizeClass.HEIGHT_DP_MEDIUM_LOWER_BOUND ) MyScreen( showTopAppBar = showTopAppBar, /* ... */ ) } Link: h"ps://developer.android.com/develop/ui/compose/layouts/adaptive/use-window-size-classes
  48. Window Size Classes (Compose) @Composable fun MyApp( windowSizeClass: WindowSizeClass =

    currentWindowAdaptiveInfo().windowSizeClass ) { val showTopAppBar: Boolean = windowSizeClass.isHeightAtLeastBreakpoint( WindowSizeClass.HEIGHT_DP_MEDIUM_LOWER_BOUND ) MyScreen( showTopAppBar = showTopAppBar, /* ... */ ) } Link: h"ps://developer.android.com/develop/ui/compose/layouts/adaptive/use-window-size-classes
  49. Window Size Classes (Compose) @Composable fun MyApp( windowSizeClass: WindowSizeClass =

    currentWindowAdaptiveInfo().windowSizeClass ) { val showTopAppBar: Boolean = windowSizeClass.isHeightAtLeastBreakpoint( WindowSizeClass.HEIGHT_DP_MEDIUM_LOWER_BOUND, // = 480 ) MyScreen( showTopAppBar = showTopAppBar, /* ... */ ) } Link: h"ps://developer.android.com/develop/ui/compose/layouts/adaptive/use-window-size-classes
  50. WebtoonLayoutSize Width 만을 기준으로 하는 Custom 창 크기 클래스를 정의하여

    사용한다 sealed interface WebtoonLayoutSize { data object COMPACT : WebtoonLayoutSize data object MEDIUM : WebtoonLayoutSize data object EXPANDED : WebtoonLayoutSize companion object { internal fun compute(dpWidth: Float): WebtoonLayoutSize { return when { dpWidth < 600 -> COMPACT dpWidth < 840 -> MEDIUM else -> EXPANDED } } ...
  51. WebtoonLayoutSize @Composable fun currentWebtoonLayoutSize(): WebtoonLayoutSize { val windowSize = with(LocalDensity.current)

    { currentWindowSize().toSize().toDpSize() } return WebtoonLayoutSize.compute( dpWidth = windowSize.width.value, ) }
  52. WebtoonLayoutSize @Composable fun currentWebtoonLayoutSize(): WebtoonLayoutSize { val windowSize = with(LocalDensity.current)

    { currentWindowSize().toSize().toDpSize() // Compose Material3 Adaptive } return WebtoonLayoutSize.compute( dpWidth = windowSize.width.value, ) } Link: h"ps://cs.android.com/androidx/pla$orm/frameworks/suppo#/+/.../AndroidWindowAdaptiveInfo.android.kt
  53. WebtoonLayoutSize @Composable fun currentWebtoonLayoutSize(): WebtoonLayoutSize { val windowSize = LocalWindowInfo.current.containerDpSize

    // Compose UI 1.10.0-alpha03 return WebtoonLayoutSize.compute( dpWidth = windowSize.width.value, ) } Link: h"ps://android-review.googlesource.com/c/pla$orm/frameworks/suppo#/+/3747230 TBD
  54. WebtoonTheme CompositionLocal을 이용하여, WebtoonLayoutSize를 제공하는 Custom 테마를 정의한다 @Composable fun

    WebtoonTheme( layoutSize: WebtoonLayoutSize = currentWebtoonLayoutSize(), content: @Composable () -> Unit ) { CompositionLocalProvider( LocalWebtoonLayoutSize provides layoutSize, ... ) { MaterialTheme(...) } }
  55. WebtoonTheme CompositionLocal을 이용하여, WebtoonLayoutSize를 제공하는 Custom 테마를 정의한다 @Composable fun

    WebtoonTheme( layoutSize: WebtoonLayoutSize = currentWebtoonLayoutSize(), content: @Composable () -> Unit ) { CompositionLocalProvider( LocalWebtoonLayoutSize provides layoutSize, ... ) { MaterialTheme(...) } }
  56. WebtoonTheme internal val LocalWebtoonLayoutSize = staticCompositionLocalOf<WebtoonLayoutSize> { ... } object

    WebtoonTheme { val layoutSize: WebtoonLayoutSize @Composable @ReadOnlyComposable get() = LocalWebtoonLayoutSize.current }
  57. WebtoonTheme internal val LocalWebtoonLayoutSize = staticCompositionLocalOf<WebtoonLayoutSize> { ... } object

    WebtoonTheme { val layoutSize: WebtoonLayoutSize @Composable @ReadOnlyComposable get() = LocalWebtoonLayoutSize.current } val WebtoonLayoutSize.contentsMaxWidthWithMargin: Dp get() = when (this) { WebtoonLayoutSize.COMPACT -> 512.dp WebtoonLayoutSize.MEDIUM -> 598.dp WebtoonLayoutSize.EXPANDED -> 854.dp }
  58. 최대 너비 제한하기 (Compose) 필요한 컴포넌트에 maxWidth 값을 설정한다 WebtoonTheme

    { LazyVerticalGrid( columns = GridCells.Fixed(spanCount), modifier = Modifier .sizeIn(maxWidth = WebtoonTheme.layoutSize.contentsMaxWidthWithMargin) .padding(horizontal = WebtoonTheme.layoutSize.contentsHorizontalMargin), ) { ... } }
  59. 최대 너비 제한의 문제점 (1) 가로 스크롤되는 컴포넌트의 동작이 어색하다

    clipChildren = true (기본) clipChildren = false UI가 잘려 보인다 터치 이벤트가 무시된다
  60. 최적화된 레이아웃으로 표시하기 • 2가지 방법을 모두 사용한다 • 적응형

    레이아웃 • 반응형 레이아웃 모든 유형의 기기에서 우수한 사용자 환경을 제공한다
  61. 적응형 레이아웃 • 안드로이드에서 공식적으로 가장 권장하는 방법이다 • 하지만

    서비스 관점에서는 리소스가 많이 든다 • 디자인: 화면마다 디자인 배치가 달라지는 가이드를 여러벌 만들어야 한다 • 개발: 레이아웃을 여러벌 구현하고, 구성 변경 시마다 교체해야 한다 • QA: 화면 크기마다 동작을 테스트 해야 한다 다양한 화면 크기에 따라 최적화된 대체 레이아웃을 제공한다
  62. 적응형 레이아웃 구성 변경 시마다 새로 생성되도록, '사용 가능한 너비

    한정자'로 구분한다 res/layout/main_activity.xml <com.google.android.material.bottomnavigation.BottomNavigationView /> res/layout-w600dp/main_activity.xml <com.google.android.material.navigationrail.NavigationRailView /> res/layout-w1240dp/main_activity.xml <com.google.android.material.navigation.NavigationView /> Link: h"ps://developer.android.com/develop/ui/views/layout/build-responsive-navigation#responsive_ui_navigation
  63. 적응형 레이아웃 구성 변경 시마다 교체하지 않으려면, '최소 너비 한정자'를

    사용하게 된다 res/layout/main_activity.xml res/layout-sw600dp/main_activity.xml res/layout-sw1240dp/main_activity.xml <activity android:name=".MainActivity" android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" /> Link: h"ps://developer.android.com/develop/ui/views/layout/suppo#-multi-window-mode#con%g
  64. 적응형 레이아웃 • 안드로이드에서 공식적으로 가장 권장하는 방법이다 • 하지만

    서비스 관점에서는 리소스가 많이 든다 • 디자인: 화면마다 디자인 배치가 달라지는 가이드를 여러벌 만들어야 한다 • 개발: 레이아웃을 여러벌 구현하고, 구성 변경 시마다 교체해야 한다 • QA: 화면 크기마다 동작을 테스트 해야 한다 • 주요 화면 일부에만 적용하는 것이 현실적이다 • 또는 디자인 시스템 컴포넌트에만 적용하게 된다 (ex. Bottom Sheet) • 대부분은 반응형 레이아웃으로 적용하게 된다 다양한 화면 크기에 따라 최적화된 대체 레이아웃을 제공한다
  65. 반응형 레이아웃 디스플레이 공간을 채우기 위해 단순히 UI 요소를 늘리지

    말 것 글로벌웹툰 앱에서 대부분의 화면은 목록형 UI • Grid SpanCount • Linear는 Grid로 변경 다양한 화면 크기에 반응하는 하나의 레이아웃을 관리한다
  66. 목록형 UI: RecyclerView 화면 크기에 따라 GridLayoutManager의 spanCount를 조정한다 val

    spanCount: Int = ... binding.recyclerView.layoutManager = GridLayoutManager(view.context, spanCount)
  67. 목록형 UI: RecyclerView 항목 사이에 간격을 추가할 때는 ItemDecoration을 사용한다

    val spanCount: Int = ... binding.recyclerView.layoutManager = GridLayoutManager(view.context, spanCount) binding.recyclerView.removeItemDecorations() // ઺୏غ૑ ঋب۾ ઱੄! binding.recyclerView.addItemDecoration( GridSpaceItemDecoration( spanCount = spanCount, verticalSpace = resources.getDimensionPixelSize(R.dimen.gwds_layout_row_gap), horizontalSpace = resources.getDimensionPixelSize(R.dimen.gwds_layout_gutter), ) )
  68. 목록형 UI: RecyclerView class GridSpaceItemDecoration( private val spanCount: Int, @Px

    private val verticalSpace: Int, @Px private val horizontalSpace: Int, ) : RecyclerView.ItemDecoration() { override fun getItemOffsets(...) { val position = parent.getChildAdapterPosition(view) val column = position % spanCount outRect.left = column * horizontalSpace / spanCount outRect.right = horizontalSpace - (column + 1) * horizontalSpace / spanCount if (position >= spanCount) { outRect.top = verticalSpace } } }
  69. 목록형 UI: RecyclerView 리소스 한정자로 화면 크기에 적절한 spanCount를 선택할

    수 있을까? val spanCount: Int = resources.getInteger(R.integer.gwds_layout_item_span_count) binding.recyclerView.layoutManager = GridLayoutManager(view.context, spanCount) res/values/integers.xml <integer name="gwds_layout_title_span_count">3</integer> res/values-sw600dp/integers.xml <integer name="gwds_layout_title_span_count">4</integer> res/values-sw840dp/integers.xml <integer name="gwds_layout_title_span_count">5</integer>
  70. 목록형 UI: RecyclerView object GWLayoutUtil { fun calculateAdaptiveGridSpanCount( resources: Resources,

    width: Int, minSpanCount: Int = 1, sideMargin: Int = resources.getDimensionPixelSize(...), itemMinWidth: Int = resources.getDimensionPixelSize(...), gutter: Int = resources.getDimensionPixelSize(...), ): Int { val availableWidth = width - (sideMargin * 2) val measuredSpanCount = (availableWidth + gutter) / (itemMinWidth + gutter) return max(minSpanCount, measuredSpanCount) } }
  71. 목록형 UI: RecyclerView 동적으로 계산하여 spanCount를 다르게 적용한다 val spanCount

    = GWLayoutUtil.calculateAdaptiveGridSpanCount( resources = resources, width = width, ) binding.recyclerView.layoutManager = GridLayoutManager(view.context, spanCount)
  72. 목록형 UI: RecyclerView 화면 너비를 측정하는 방법은? val spanCount =

    GWLayoutUtil.calculateAdaptiveGridSpanCount( resources = resources, width = width, ) binding.recyclerView.layoutManager = GridLayoutManager(view.context, spanCount)
  73. 화면 너비 측정하기 (View) public inline fun View.doOnGlobalLayout(crossinline action: (view:

    View) -> Unit) { viewTreeObserver.addOnGlobalLayoutListener( object : ViewTreeObserver.OnGlobalLayoutListener { override fun onGlobalLayout() { viewTreeObserver.removeOnGlobalLayoutListener(this) action(this@doOnGlobalLayout) } } ) } view.doOnGlobalLayout { // Use it.width } Link: h"ps://stackove&low.com/a/8245468
  74. 화면 너비 측정하기 (View) class MainActivity : Activity() { override

    fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val container: ViewGroup = binding.container container.addView(object : View(this) { override fun onConfigurationChanged(newConfig: Configuration?) { super.onConfigurationChanged(newConfig) // calculate spanCount and update UI } }) // calculate spanCount and update UI } } Link: h"ps://developer.android.com/develop/ui/compose/layouts/adaptive/use-window-size-classes
  75. 화면 너비 측정하기 (View) OnGlobalLayoutListener 대신, Custom View로 너비를 측정한다

    binding.measurer.setOnSizeChangeListener { width, height -> val spanCount = GWLayoutUtil.calculateAdaptiveGridSpanCount( resources = resources, width = width, ) binding.recyclerView.layoutManager = GridLayoutManager(view.context, spanCount) } <com.naver.linewebtoon.designsystem.foundation.GWLayoutMeasurer android:id="@+id/measurer" android:layout_width="match_parent" android:layout_height="0dp" />
  76. 화면 너비 측정하기 (View) class GWLayoutMeasurer @JvmOverloads constructor(...) : View(...)

    { private var lastSize: Size = Size(0, 0) override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { val newSize = Size(w, h) if (lastSize != newSize) { lastSize = newSize sizeChangeListener?.onSizeChanged(newSize.width, newSize.height) } } ...
  77. 화면 너비 측정하기 (View) ... fun interface OnSizeChangeListener { fun

    onSizeChanged(w: Int, h: Int) } private var sizeChangeListener: OnSizeChangeListener? = null fun setOnSizeChangeListener(listener: OnSizeChangeListener?) { sizeChangeListener = listener } /* android.widget.Space ৬ زੌೠ ௏٘ */ }
  78. 화면 너비 측정하기 (Compose) • 제약 조건을 측정하는 API •

    그런데 화면 너비에 적절한 spanCount를 계산하는데, 화면 너비를 직접 측정해야만 할까? View보다 훨씬 간단하다 BoxWithConstraints { // Use maxWidth or maxHeight } Link: h"ps://developer.android.com/develop/ui/compose/layouts/basics#constraints
  79. 목록형 UI: LazyVerticalGrid 그리드로 항목을 표시하는 컴포넌트 LazyVerticalGrid( columns =

    GridCells.Fixed(count = 3), ) { items(photos) { photo -> PhotoItem(photo) } } Link: h"ps://developer.android.com/develop/ui/compose/lists#lazy-grids
  80. 목록형 UI: LazyVerticalGrid 각 열의 최소 너비를 설정하여, 적응형으로 크기를

    조절할 수 있다 LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 128.dp), ) { items(photos) { photo -> PhotoItem(photo) } } Link: h"ps://developer.android.com/develop/ui/compose/lists#lazy-grids
  81. 목록형 UI: LazyVerticalGrid @Stable interface GridCells { class Adaptive(private val

    minSize: Dp) : GridCells { override fun Density.calculateCrossAxisCellSizes( availableSize: Int, spacing: Int ): List<Int> { val count = maxOf((availableSize + spacing) / (minSize.roundToPx() + spacing), 1) return calculateCellsCrossAxisSizeImpl(availableSize, count, spacing) } } }
  82. 목록형 UI: LazyVerticalGrid private fun calculateCellsCrossAxisSizeImpl( gridSize: Int, slotCount: Int,

    spacing: Int ): List<Int> { val gridSizeWithoutSpacing = gridSize - spacing * (slotCount - 1) val slotSize = gridSizeWithoutSpacing / slotCount val remainingPixels = gridSizeWithoutSpacing % slotCount return List(slotCount) { slotSize + if (it < remainingPixels) 1 else 0 } }
  83. 목록형 UI: LazyVerticalGrid 각종 여백도 한번에 정의할 수 있다 LazyVerticalGrid(

    columns = GridCells.Adaptive(minSize = WebtoonTheme.layoutSize.itemMinWidth), state = rememberLazyGridState(), contentPadding = PaddingValues(horizontal = WebtoonTheme.layoutSize.sideMargin), verticalArrangement = Arrangement.spacedBy(WebtoonTheme.layoutSize.rowGap), horizontalArrangement = Arrangement.spacedBy(WebtoonTheme.layoutSize.gutter), ) { items(...) { ... } } Link: h"ps://developer.android.com/develop/ui/compose/lists#lazy-grids
  84. 목록형 UI: LazyVerticalGrid 배너처럼 한 줄 전체를 채워야 하는 경우도

    간단하다 LazyVerticalGrid( columns = GridCells.Adaptive(minSize = WebtoonTheme.layoutSize.itemMinWidth), state = rememberLazyGridState(), contentPadding = PaddingValues(horizontal = WebtoonTheme.layoutSize.sideMargin), verticalArrangement = Arrangement.spacedBy(WebtoonTheme.layoutSize.rowGap), horizontalArrangement = Arrangement.spacedBy(WebtoonTheme.layoutSize.gutter), ) { item(span = { GridItemSpan(maxLineSpan) }) { ... } items(...) { ... } } Link: h"ps://developer.android.com/develop/ui/compose/lists#lazy-grids
  85. 목록형 UI: Hero 그리드 • View로 구현하는 것은 난이도가 있다

    • Custom View 구현 • 동적으로 교체 (ViewStub) • Jetpack Compose 권장! A/B 테스트처럼 조건부로 UI가 크게 변경되어야 하는 경우 Link: h"ps://developer.android.com/develop/ui/compose/layouts/custom if (showAsHero) { HeroVerticalGrid(...) } else { VerticalGrid(...) }
  86. 목록형 UI: Pager • 페이지 레이아웃이 비율대로 늘어나는 경우, 너비를

    가득 채우면 크게 확대되는 문제가 있다 • 각 페이지의 크기를 고정해야 한다 • ViewPager, ViewPager2 에는 기능이 없다 가로 또는 세로로 한 페이지씩만 넘어가는 컴포넌트
  87. 목록형 UI: Pager • 페이지 레이아웃이 비율대로 늘어나는 경우, 너비를

    가득 채우면 크게 확대되는 문제가 있다 • 각 페이지의 크기를 고정해야 한다 • ViewPager, ViewPager2 에는 기능이 없다 • Jetpack Compose 권장! • PageSize.Fixed(358.dp) • 반대로 너비가 좁은 상황에서는 잘린다 가로 또는 세로로 한 페이지씩만 넘어가는 컴포넌트 Link: h"ps://developer.android.com/develop/ui/compose/layouts/pager#custom-page
  88. 목록형 UI: Pager class Fixed(val pageSize: Dp) : PageSize {

    override fun Density.calculateMainAxisPageSize( availableSpace: Int, pageSpacing: Int ): Int { return pageSize.roundToPx() } } HorizontalPager( pageSize = Fixed(pageSize = 358.dp), ) { page -> ... }
  89. 목록형 UI: Pager class MaxWidth(val pageSize: Dp) : PageSize {

    override fun Density.calculateMainAxisPageSize( availableSpace: Int, pageSpacing: Int ): Int { return pageSize.roundToPx().coerceAtMost(maximumValue = availableSpace) } } HorizontalPager( pageSize = MaxWidth(pageSize = 358.dp), ) { page -> ... }
  90. 글로벌웹툰에서 구현한 방법 최대 너비 제한하기 • View: 최소 너비

    한정자 • ConstraintLayout • android:maxWidth • layout_constraintWidth_max • Compose: 창 크기 클래스 • WebtoonLayoutSize View보다는 Compose로 구현하는 것을 권장. 최적화된 레이아웃으로 표시하기 (목록형 UI) • View: Grid SpanCount • GridLayoutManager • GridSpaceItemDecoration • GWLayoutMeasurer • Compose: ! • GridCells.Adaptive • Arrangement.spacedBy • Pager (Compose only) • PageSize.MaxWidth
  91. 적응형 레이아웃 적용 • 반응형 UI 탐색 • View: BottomNavigationView,

    NavigationRailView • Compose: NavigationBar, NavigationRail • NavigationSuiteScaffold 디스플레이를 낭비하지 않도록 개선한다 Link: h"ps://developer.android.com/develop/ui/compose/layouts/adaptive/build-adaptive-navigation
  92. 적응형 레이아웃 적용 디스플레이를 낭비하지 않도록 개선한다 Link: h"ps://developer.android.com/develop/ui/compose/layouts/adaptive/canonical-layouts#list-detail •

    반응형 UI 탐색 • View: BottomNavigationView, NavigationRailView • Compose: NavigationBar, NavigationRail • NavigationSuiteScaffold • 표준 레이아웃: List-Detail 패턴 • View: Activity Embedding, SlidingPaneLayout • Compose: ListDetailPaneScaffold
  93. ListDetailPaneScaffold( ... listPane = { AnimatedPane { ListContent(...) } },

    detailPane = { AnimatedPane { DetailContent(...) } }, ) List Detail Detail
  94. 적응형 레이아웃 적용 디스플레이를 낭비하지 않도록 개선한다 Link: h"ps://developer.android.com/guide/navigation/navigation-3 •

    반응형 UI 탐색 • View: BottomNavigationView, NavigationRailView • Compose: NavigationBar, NavigationRail • NavigationSuiteScaffold • 표준 레이아웃: List-Detail 패턴 • View: Activity Embedding, SlidingPaneLayout • Compose: ListDetailPaneScaffold • Navigation 3 (alpha)
  95. NavHost(...) { composable<Route.ChatsList> { ChatList(...) } composable<Route.ChatThread> { ChatScreen(...) }

    ) NavDisplay(...) { backStackKey -> when (backStackKey) { is Pane.ChatsList -> NavEntry( key = backStackKey, ) { ChatList(...) } is Pane.ChatThread -> NavEntry( key = backStackKey, ) { ChatScreen(...) } } ) Navigation 3 (alpha) Navigation
  96. Navigation 3 (alpha) NavDisplay(...) { backStackKey -> when (backStackKey) {

    is Pane.ChatsList -> NavEntry( key = backStackKey, metadata = ListDetailSceneStrategy.listPane(), ) { ChatList(...) } is Pane.ChatThread -> NavEntry( key = backStackKey, metadata = ListDetailSceneStrategy.detailPane(), ) { ChatScreen(...) } } )
  97. 사용성과 접근성 개선 • 사용성 • 멀티 인스턴스 큰 화면

    기기에서 서비스를 잘 이용할 수 있도록 개선한다 App UI System UI Drag & Drop Link: h"ps://developer.android.com/develop/ui/compose/layouts/adaptive/suppo#-multi-window-mode#multi-instance
  98. 사용성과 접근성 개선 • 사용성 • 멀티 인스턴스 • Caption

    Bar (WindowInsets) 큰 화면 기기에서 서비스를 잘 이용할 수 있도록 개선한다 CaptionBar Link: h"ps://developer.android.com/develop/ui/compose/layouts/adaptive/suppo#-desktop-windowing
  99. 사용성과 접근성 개선 • 사용성 • 멀티 인스턴스 • Caption

    Bar • 접근성 • 외부 입력 장치와의 상호작용 개선 • 방향키 탐색 • 키보드 단축키 지원 • 마우스 포인터 아이콘 • 스타일러스 입력 큰 화면 기기에서 서비스를 잘 이용할 수 있도록 개선한다 Link: h"ps://developer.android.com/develop/ui/compose/layouts/adaptive/suppo#-desktop-windowing
  100. Summary 태블릿 사용자 지표 확인 • Google Play Console •

    Firebase Analytics 대형 화면 대응은 선택이 아닌 필수. Android 16 동작 확인 • 방향, 크기 조절 가능 여부, 가로세로 비율 제한 무시 대형 화면 앱 품질 검토 • TIER 3, 2, 1 • UX, 외부 입력 장치 글로벌웹툰 레이아웃 개선 • 최대 너비 제한 • 최적화된 레이아웃으로 표시 Jetpack Compose 권장 • Window Size Classes • UI/UX, 접근성 Next Step • List-Detail 패턴 • 데스크톱 창 모드