읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

안드로이드에 대해 공부해본 적 없는 안드로이드 개발자가 해당 포스팅을 작성합니다. 구글 안드로이드 개발자 문서와 몇몇 블로그, 도서를 참고하여 작성하였습니다. 이번 포스팅에서는 안드로이드의 액티비티가 무엇이고 생명주기는 어떻게 구성되는지 정리합니다.

Activity란?

Android의 주요 컴포넌트로 main 메소드를 사용하여 프로그램을 실행하던 방식과 달리 Android는 수명 주기 단계에 따라 특정 콜백 함수를 호출하여 Activity를 실행한다.

Activity의 개념

Activity는 앱과 사용자의 상호작용을 위한 진입점으로 앱이 UI를 그리는 창을 제공한다. 따라서, 기능 단위로 Activity가 나뉘는 경향을 볼 수 있는데 최근엔 Single Activity로 구현하되 화면은 Fragment로 구현하는 방식을 권장한다. Fragment는 차후 다루기로 하자.

Activity에서 다른 Activity를 실행할 수 있는데 같은 앱뿐만 아니라 다른 앱의 Activity도 실행할 수 있다. 필요에 따라 다른 앱 전체를 시작하지 않고 특정 Activity 실행이 가능하다.

Activity의 등록

앱이 Activity를 사용하려면 AndroidManifest.xml에 몇몇 속성을 선언해야 한다.

Activity 선언

<manifest ... >
<application ... >
<activity android:name=".ExampleActivity" />
...
</application ... >
...
</manifest >

필수 항목은 Activity의 이름을 정의하는 name 속성이다. 그외 별도 테마, 런쳐 Activity 여부 등 다른 속성들도 지정할 수 있다.

Intent Filter 선언

<activity android:name=".ExampleActivity" android:icon="@drawable/app_icon">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>

외부 앱으로부터 신호를 받아 특정 액션을 취할 수 있게끔 만들어주는 Intent Filter도 정의할 수 있다.

Permission 선언

<manifest>
<activity android:name="...."
android:permission=”com.google.socialapp.permission.SHARE_POST”
/>

Activity의 동작이 특정 권한을 요구하는 경우 위와 같이 정의할 수 있다. 특정 Activity가 다른 앱으로부터 실행되어야 하는 경우 이렇게 권한을 설정했다면 다른 앱 또한 호출하고자 하는 Activity와 동일한 권한을 선언한 상태여야 한다.

<manifest>
<uses-permission android:name="com.google.socialapp.permission.SHARE_POST" />
</manifest>

Activity Life Cycle

  • 액티비티 생명주기(life cycle) : 액티비티가 생성되어 소멸하기까지의 과정을 말하며, Activity 클래스는 액티비티의 상태를 관측, 조회할 수 있도록 여러 콜백 함수를 제공한다. 콜백 함수에 액티비티의 상태가 바뀔 때 앱이 어떻게 동작해야 하는지 구현한다.
    액티비티의 상태는 동작을 기준으로 크게 3가지로 구분한다.
  1. 활성 : 액티비티 화면이 출력되고 있으며 사용자가 이벤트를 발생시킬 수 있음
  2. 일시 정지 : 액티비티의 화면이 출력되고 있으나 사용자가 이벤트를 발생시킬 수 없음
  3. 비활성 : 액티비티의 화면이 출력되고 있지 않은 상태

액티비티의 시작, 중지, 종료까지의 일련의 과정을 도식화한 그림이다.

Android_Activity_LifeCycle_001.png

entire lifetime

액티비티가 처음 생성될 때 첫 번째로 호출되는 onCreate()부터 마지막에 파기되면서 한 번 호출되는 onDestroy()까지를 의미한다. onCreate()호출 시 액티비티는 global하게 상태를 설정하며 onDestroy() 호출 시 보유한 자원을 모두 release한다. 예를 들어, 액티비티가 네트워크로부터 데이터를 다운로드 받는 백그라운드 스레드를 갖고있다면 해당 스레드는 onCreate() 호출 시 생성되고 onDestroy() 호출 시 소멸한다.

visible lifetime

onStart()가 호출되고나서 onStop()이 호출될 때까지를 의미한다. 해당 기간 동안 사용자는 상호작용 가능 여부와는 상관없이 화면에서 액티비티를 볼 수 있다. 두 메소드 사이에서 개발자는 액티비티에 보여줄 리소스를 유지할 수 있으므로 onStart()호출 시 BroadcastReceiver를 등록해 UI를 변경하면서 onStop()이 호출되면 더 이상 화면에 보여지지 않기 때문에 UI를 갱신하기 위한 BroadcastReceiver를 해제할 수 있다. 이처럼 액티비티가 보여지거나 보여지지 않는 등 상황에 따라 여러 번 호출될 수 있으므로 개발자가 적절하게 사용할 수 있다.

foreground lifetime

onResume() 호출부터 onPause() 호출까지를 의미한다. 이 기간 동안 사용자는 액티비티를 볼 수 있고 액티비티가 동작하며 서로 상호작용 할 수 있다. 액티비티는 단말기가 수면상태로 진입하거나, 새로운 인텐트를 받는 등 자주 paused와 resumed 상태로 전환되므로 onPause(), onResume() 메소드에는 가급적 무거운 작업을 넣지 말아야 한다.

액티비티 콜백 메소드

onCreate()

액티비티 생성 시 최초로 호출되는 콜백 메소드이며 반드시 구현해야 하므로 UI를 표시하는 setContentView()를 여기서 호출한다. onCreate()가 완료되면 다음 콜백은 항상 onStart()이며 처음 실행된 액티비티는 onCreate() -> onStart() -> onResume()메소드까지 호출된다.

onStart()

onCreate()가 종료되고 액티비티는 ‘시작’ 상태로 전환되며 사용자에게 표시된다. 해당 콜백은 액티비티가 foreground로 나오고 상호작용하기 위해 준비하는 작업을 수행한다.

onResume()

액티비티가 사용자와 상호작용하기 직전 호출된다. onResume() 콜백이 호출된 시점에선 액티비티는 액티비티 스택 맨 위에 위치하며 모든 사용자 입력을 받는다. 따라서, 대부분 앱의 기능은 onResume() 메소드로 구현한다.

onPause()

액티비티가 포커스를 잃어 사용자와 상호작용을 할 수 없는 ‘일시중지’ 상태로 전환될 때 시스템이 호출한다. 예를 들어, backward 혹은 recent tray 버튼을 클릭한다던지 아니면 dialog가 발생해서 반투명하게 변할 때 발생한다. 만약 유저에게 UI 업데이트를 제공한다면 포커스를 잃는 ‘일시중지’ 상태에서도 계속 업데이트할 수 있다. 다만, 앱 혹은 사용자 데이터를 저장하거나 네트워크를 호출하거나 데이터베이스 트랜잭션 실행 시 onPause() 메소드에서 처리하면 안된다.

  • onPause()에서 상태 저장 및 복원을 하면 안되는 이유
    onPause()의 실행 시간은 굉장히 짧기에 저장 및 요청 작업을 처리하기엔 시간이 부족할 수 있다. 따라서, 메소드 실행이 끝나기 전에 완료되지 못할 수 있다. 네트워크 요청이나 DB 트랜잭션 실행, 데이터 저장은 실행 시간이 보장되어야 하는 경우가 있는데 이처럼 부하가 큰 종료 작업은 액티비티가 비활성 상태로 전환된 이후 실행되는 onStop() 메소드에서 처리해야 한다.
    onPause() 작업이 끝나고서 호출되는 다음 콜백은 ‘일시중지’ 상태로 전환된 뒤 발생하는 상황에 따라 onStop() 혹은 onResume()이다.

onStop()

액티비티가 더 이상 유저에게 표시되지 않으면 시스템은 onStop()을 호출한다. 현재 액티비티를 제거하거나, 새로운 액티비티를 시작하거나, 기존 Stopped 액티비티가 Resume되어 재진입하게 되는 상황에서 중지 작업을 수행한다.
액티비티가 사용자에게 보여지지 않는 상태이므로 필요없는 리소스를 해제하거나 조정해야 한다. 애니메이션을 일시적으로 중단하거나 위치 정보 업데이트 룰을 세밀함에서 대략적으로 변경한다는 방법을 취할 수 있다.
이후 호출되는 콜백은 액티비티가 다시 사용자와 상호작용을 위해 시작되면 onRestart()이며 액티비티가 완전히 종료되면 onDestroy()이다.

  • 멀티 윈도우 환경에서 액티비티의 UI 활동 보장
    멀티 윈도우에서는 두 개의 앱이 한 화면에 동시에 위치한다. 그리고 하나의 앱과 상호작용 하는 동안 다른 앱은 아직 전면에 노출된 상태이므로 onPause 상태가 된다. 만약 onPause()에서 자원을 release하면 앱이 화면에 있음에도 정상적으로 동작할 수 없다. 따라서, 멀티 윈도우 환경을 고려한다면 자원 release를 onStop()에서 정의하라고 가이드를 제시하고 있다. 다만, 대부분의 경우엔 onStop이나 onDestroy에서 자원을 release하고 있기에 크게 주요한 지식은 아니나 참고사항으로 알아두면 좋겠다. 추후 폴더블 기기등 멀티태스킹 환경에서의 수명 주기도 다뤄볼 예정이다.

onRestart()

‘중지’ 상태의 액티비티를 다시 시작할 때 호출되는 콜백 메소드이다. 액티비티가 중지된 시점부터의 상태를 복원하는 역할을 수행한다. 해당 콜백 뒤에는 항상 onStart() 메소드가 호출된다.

onDestroy()

액티비티가 제거되기 전에 호출되는 콜백이다. 호출되는 시나리오는 크게 두 가지로 요약할 수 있다.

  1. 사용자가 액티비티를 완전히 제거하거나 finish()가 호출되어 종료되는 경우
  2. 구성 변경(기기 회전, 멀티 윈도우 모드로 인한 창 크기 변경)으로 인해 시스템이 일시적으로 액티비티를 소멸시키는 경우
    onDestroy() 메소드는 액티비티가 마지막으로 수신하는 콜백으로 일반적인 경우, 액티비티와 액티비티가 포함된 프로세스가 제거될 때 액티비티의 모든 리소스를 release하도록 구현된다.
    다만, 구성 변경으로 인해 액티비티를 종료하는 경우 즉시 새로운 액티비티 인스턴스를 생성하므로 새로운 구성을 가지고 새로운 인스턴스에 관해 onCreate()를 호출한다.

참고자료

  1. Android Developers - The Activity Lifecycle
  2. Android Developers - Introduction to Activities

블로그에 비교적 자세히 채용 진행과정을 작성해서 그런지 검색 알고리즘을 타고 들어오시는 분들이 많다. 사실 합격했을 땐 나정도면 꽤 괜찮게 준비된 사람이라고 생각했으나 막상 부서 들어와서 사수, 직원들과 일해보니 그 분들과 비교하면 그저 코드몽키였다는 인상을 받는다.

이런 생각들을 하던 와중 블로그에 작성한 글들을 보고 외부강연이나 수기 공유 등의 오퍼가 종종 들어온다. 종종이란 표현도 과분하게 생각보다 자주 받는다. 하지만 이제껏 단 한번도 수락한 적은 없다. '내가 과연 회사 이름 걸고 나와서 강연을 진행할만한 인재인가'라는 질문에 아직은 "그렇다"라고 자신있게 답을 내릴 수 없기 때문이다. 당장 매일 소통하며 코드를 함께 작성하는 옆의 직원으로부터 압도적인 실력차를 경험하고 있는데 무슨 자신감으로 얼굴을 걸 수 있겠는가. 물론 그 분은 시니어고 나는 주니어니까 기준이 좀 다르다만 외부활동의 특성 상어디 절벽에 숨겨져 있는 무공비급 공개하듯이 광고를 할텐데 그 정도의 수치심을 견뎌낼만큼 뻔뻔하질 못해서 생긴 고민이지 싶다.

나는 굉장히 속성으로 1년 반 정도 준비하고 직군을 전환해서 그런지 남들보다 지식의 수준이 그리 깊진 못하다. 새로운 지식을 잘 정리하고 학습할 수 있다는 점만 보여줌으로써 합격했는데 과연 어느 정도의 준비를 갖춰야 당당히 얼굴을 걸고 외부활동을 할 수 있을지 궁금하다. 세상엔 실력자가 너무 많아 매일매일 강제로 겸손을 주입당하는 중이다. 사람들이 좀 덜 노력했으면 좋겠는데...

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

Jetpack Compose의 레이아웃 코드랩에서 실습한 내용들을 정리합니다. Compose가 레이아웃 렌더링 시 크기 측정을 단 한번만 수행하는데 필요에 의해 미리 1회 이상 측정해야 하는 경우를 위한 측정 도구를 실습합니다. 코드랩 실습에 요구되는 시간은 대략 1시간 정도로 학습 목적을 고려하면 대략 2 - 3시간 정도를 투자해야 합니다. 분량이 너무 많아 챕터 별로 분할하여 업로드합니다.

  1. 내장 기능(Intrinsic Measure)

Jetpack Compose를 사용하여 개발함에 있어 항상 유념해야 하는 점은 하위 요소(Child Element)를 한 번만 측정할 수 있다는 점이다. 하위 요소를 두 번 측정하도록 작성하면 Runtime Exception이 발생한다. 다만, 하위 요소를 측정하기 앞서 하위 요소에 대한 정보가 필요한 경우도 있다.

Intrinsic 기능을 사용하면 하위 요소가 실제로 measure되기 전에 하위 요소를 쿼리할 수 있다.

  • (min|max)InstrinsicWidth : width가 주어졌을 때 컴포저블을 그리기 위해 필요한 최소/최대 width 반환
  • (min|max)IntrinsicHeight : height가 주어졌을 때 컴포저블을 그리기 위해 필요한 최소/최대 height 반환

만약 width가 무한대인 Text 컴포저블에 대해 minIntrinsicHeight를 요청하면 single line으로 Text를 배치했을 때 필요한 height를 반환한다.

Intrinsic Measure 실제 사례

다음 그림처럼 화면에 구분선으로 구분된 두 개의 Text 컴포저블을 만들어보자.

Codelab_Jetpack_Compose_layout_034

Text 컴포저블을 클릭할 수 있게 하고자 함이 아니니 weight를 동일하게 부여하고 중앙에 구분선으로 작용할 Divider 컴포저블을 선언한다. 구분선의 높이는 텍스트의 높이만큼, 너비는 1.dp를 부여한다.

@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
Row(modifier = modifier) {
Text(
modifier = Modifier
.weight(1f)
.padding(start = 4.dp)
.wrapContentWidth(Alignment.Start),
text = text1
)
Divider(color = Color.Black, modifier = Modifier.fillMaxHeight().width(1.dp))
Text(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
.wrapContentWidth(Alignment.End),
text = text2
)
}
}
@Preview
@Composable
fun TwoTextsPreview() {
Surface {
TwoTexts(text1 = "Hi", text2 = "there")
}
}

그러나 preview로 본 화면은 의도대로 동작하지 않는다. 구분선이 화면 전체 높이를 차지하여 비정상적으로 길어졌다.

Codelab_Jetpack_Compose_layout_035

Row 컴포저블이 각 하위 요소들을 측정하기 때문에 Text의 높이를 사용하여 Divider의 높이에 제약을 설정하지 못했다. 따라서, 텍스트의 높이에 맞추려고 부여했던 Divider의 Modifier에 입력한 fillMaxHeight로 인해 전체 화면 높이만큼 차지하였다. 의도대로 구현을 위해 Row 컴포저블의 Modifier에 height(Intrinsic.Min)을 부여하여 해결할 수 있다.

height(Intrinsic.Min)은 하위 요소의 크기를 결정할 때 minIntrinsicHeight를 요청한다. 추가로, 하위 요소들이 하위 요소를 갖는다면 재귀적으로 요청한다. 그리고 하위 요소들이 반환한 minIntrinsicHeight 값들 중 가장 큰 값이 Row 컴포저블의 minIntrinsicHeight 값이 된다.

Divider 컴포저블은 별도의 컨텐츠가 없어 Row 컴포저블의 하위 요소들인 Text 컴포저블들의 minIntrinsicHeight 중 큰 값이 Row의 minIntrinsicHeight가 된다. 따라서, Divider는 Row의 height에 대해 fillMaxHeight하기로 했으므로 Row 컴포저블로부터 전달받은 height 값만큼만 늘어나게 된다.

@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
Row(modifier = modifier.height(IntrinsicSize.Min)) {
Text(
modifier = Modifier
.weight(1f)
.padding(start = 4.dp)
.wrapContentWidth(Alignment.Start),
text = text1
)
Divider(color = Color.Black, modifier = Modifier.fillMaxHeight().width(1.dp))
Text(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
.wrapContentWidth(Alignment.End),
text = text2
)
}
}
@Preview
@Composable
fun TwoTextsPreview() {
LayoutsCodelabTheme {
Surface {
TwoTexts(text1 = "Hi", text2 = "there")
}
}
}

Codelab_Jetpack_Compose_layout_036

Intrinsic Measure Customizing

대부분의 경우 기본적으로 제공되는 기능만으로도 충분하다. 그러나, Intrinsic Measure 방식을 목적에 맞게 커스터마이징하고자 하는 경우가 있을 수 있다. 이에 대해 Custom Layout 정의 시 MeasurePolicy 인터페이스를 구현하거나 Custom Modifier 작성 시 Density 인터페이스를 구현하는 경우로 나눌 수 있겠다.

  1. Custom Layout 정의 시 MeasurePolicy 구현
@Composable
fun MyCustomComposable(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
return object : MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
// Measure and layout here
}
override fun IntrinsicMeasureScope.minIntrinsicWidth(
measurables: List<IntrinsicMeasurable>,
height: Int
) = {
// Logic here
}
// Other intrinsics related methods have a default value,
// you can override only the methods that you need.
// Ex. (min|max)Intrinsic(Width|Height)
}
}

주어진 레이아웃이 아니라 특정 목적에 맞게끔 레이아웃을 커스터마이징하고자 할 때, Intrinsic Measure 방식도 수정하고 싶다면 MeasurePolicy 인터페이스를 override하여 재정의할 수 있다. 예를 들자면, (min|max)Intrinsic(Width|Height) 함수들을 overriding할 수 있다.

  1. custom Modifier 정의 시 LayoutModifier 인터페이스 구현
fun Modifier.myCustomModifier(/* ... */) = this.then(object : LayoutModifier {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
// Measure and layout here
}
override fun IntrinsicMeasureScope.minIntrinsicWidth(
measurable: IntrinsicMeasurable,
height: Int
): Int = {
// Logic here
}
// Other intrinsics related methods have a default value,
// you can override only the methods that you need.
})

Modifier 인터페이스 커스터마이징 시에도 Density.(min|max)Intrinsic(Width|Height)Of 함수를 override하여 Intrinsic Measure 방식을 커스터마이징 할 수 있다고 설명한다. 그러나 예제 코드에서는 Density 타입을 발견할 수가 없는데 IntrinsicMeasureScopeDensity 타입을 상속받기에 Density.(min|max)Intrinsic(Width|Height)Of 함수를 override한다고 설명을 첨부한 듯하다.

참고자료

  1. Google Codelab - Jetpack Compose Layout #10 내장 기능

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

Jetpack Compose의 레이아웃 코드랩에서 실습한 내용들을 정리합니다. 해당 챕터에서 다루는 ConstraintLayout은 XML 기반 뷰 정의할 때 많이 쓰던 ConstraintLayout을 Compose 버전으로 바꿔서 도입된 라이브러리입니다. 따라서, 기존 뷰 정의 시 사용했던 레이아웃과 유사한 점이 있습니다. 컴포저블 간 Constraint를 적용하고 가이드라인 등 Helper 클래스를 사용하여 컴포저블 배치를 다른 컴포저블에 의존(연결)하게끔 정의합니다. 코드랩 실습에 요구되는 시간은 대략 1시간 정도로 학습 목적을 고려하면 대략 2 - 3시간 정도를 투자해야 합니다. 분량이 너무 많아 챕터 별로 분할하여 업로드합니다.

ConstraintLayout

Compose 도입으로 UI 트리를 사용해 화면을 구성함에 따라 공식적으로 ConstraintLayout은 가급적 권장하고 있지 않다. 오히려 복잡한 정렬이 필요한 경우 하위 요소들을 제어할 수 있는 커스텀 레이아웃 구성함이 바람직하다.

내장 레이아웃이 아니므로 별도의 라이브러리 import가 필요하다.

// build.gradle
implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-rc01"

ConstraintLayout 사용을 위해 기억해야 할 점들이 있다.

  • ConstraintLayout의 각 컴포저블에는 참조가 연결되어야 하며 참조는 createRefs(), createRef()를 사용하여 생성한다.
  • Constraint는 constraintAs Modifier를 사용하여 연결한다. 해당 Modifier는 참조를 매개변수로 사용하며 본문 람다에 제약조건을 지정할 수 있다.
  • 제약 조건은 linkTo 혹은 다른 함수 등을 사용하여 지정한다.
  • parent는 ConstraintLayout 컴포저블 자체를 의미한다.
@Composable
fun ConstraintLayoutContent() {
ConstraintLayout {
// 컴포저블에 대해 레퍼런스를 생성한다.
val (button, text) = createRefs()
Button(
onClick = { /* Do something */ },
// Button 컴포저블에 "button" 참조를 할당한다.
// ConstraintLayout의 top 부분에 연결한다.
modifier = Modifier.constrainAs(button) {
top.linkTo(parent.top, margin = 16.dp)
}
) {
Text("Button")
}
// Text 컴포저블에 text 참조를 할당한다.
// Button 컴포저블 botton에 연결한다.
Text("Text", Modifier.constrainAs(text) {
top.linkTo(button.bottom, margin = 16.dp)
})
}
}
@Preview
@Composable
fun ConstraintLayoutContentPreview() {
ConstraintLayoutContent()
}

Button과 Text 컴포저블을 선언하고 참조를 생성한 뒤 할당하였다. 각 컴포저블에 대해 어디에 연결되어야 하는지 linkTo 함수를 사용하여 연결하였다. margin을 부여하여 간격도 띄울 수 있다.

Codelab_Jetpack_Compose_layout_029

만약 버튼 하단의 텍스트를 레이아웃의 정중앙에 오도록 하려면 start와 end를 parent의 가장자리로 설정하는 centerHorizontallyTo 함수를 사용하면 된다.

@Composable
fun ConstraintLayoutContent() {
ConstraintLayout {
... // 참조 생성과 버튼은 이전과 동일
Text("Text", Modifier.constrainAs(text) {
top.linkTo(button.bottom, margin = 16.dp)
// ConstraintLayout의 수평 중앙으로 오게끔 설정
centerHorizontallyTo(parent)
})
}
}

마치 뷰 기반 xml에서 ConstraitnLayout을 사용했을 때와 활용법이 비슷하다. 다만, UI 요소끼리 연결하는 방식이 좀 더 programmatic하다.

Codelab_Jetpack_Compose_layout_030

Helper

ConstraintLayout의 더 나은 활용을 위해 사용하는 가이드라인, 배리어, 체인 등을 지원한다.

@Composable
fun ConstraintLayoutContent() {
ConstraintLayout {
val (button1, button2, text) = createRefs()
Button(
onClick = { /* Do something */ },
modifier = Modifier.constrainAs(button1) {
top.linkTo(parent.top, margin = 16.dp)
}
) {
Text("Button 1")
}
Text("Text", Modifier.constrainAs(text) {
top.linkTo(button1.bottom, margin = 16.dp)
centerAround(button1.end)
})
// 배리어를 생성함
val barrier = createEndBarrier(button1, text)
Button(
onClick = { /* Do something */ },
modifier = Modifier.constrainAs(button2) {
top.linkTo(parent.top, margin = 16.dp)
start.linkTo(barrier)
}
) {
Text("Button 2")
}
}
}

버튼 두 개를 선언하여 상단에 링크를 연결한다. 텍스트를 선언하여 좌측 버튼과 연결을 수행하되 중앙점을 좌측 버튼의 끝부분에 위치하도록 설정한다. 이후 텍스트와 버튼을 묶어 배리어를 생성한 뒤 우측 버튼의 시작점을 배리어로 설정하면 아래와 같이 출력된다.

Codelab_Jetpack_Compose_layout_031

참고할 점으로 배리어 등 다른 Helper들은 constraintAs 함수 내부에서 선언할 수 없으며 ConstraintLayout 본문에서만 선언할 수 있다. linkTo 함수를 사용하여 마치 레이아웃에 연결하는 것처럼 Helper와 컴포저블을 연결할 수 있다.

측정 기준 커스텀

컴포저블에 할당된 텍스트 길이가 지나치게 긴 경우 화면 밖으로 빠져나와 보이지 않는 경우가 있다. 이 문제를 해결하기 위해 콘텐츠 출력 범위를 래핑하는 데 필요한 크기를 조정하도록 기능을 제공한다.

@Composable
fun LargeConstraintLayout() {
ConstraintLayout {
val text = createRef()
val guideline = createGuidelineFromStart(fraction = 0.5f)
Text(
"This is a very very very very very very very long text",
Modifier.constrainAs(text) {
linkTo(start = guideline, end = parent.end)
}
)
}
}
@Preview
@Composable
fun LargeConstraintLayoutPreview() {
LayoutsCodelabTheme {
LargeConstraintLayout()
}
}

Codelab_Jetpack_Compose_layout_032

위 그림처럼 화면의 절반을 분할하는 가이드라인을 생성한 뒤 가이드라인 우측부터 출력하도록 작성하였다. 그 결과, 텍스트가 너무 길어 화면 밖으로 나가버렸다. 화면 밖으로 출력될 텍스트들을 줄바꿈하여 화면 안으로 집어넣고자 한다. 해당 동작을 위해 Text 컴포저블의 Modifier 중 width 동작을 다음과 같이 변경하자.

@Composable
fun LargeConstraintLayout() {
ConstraintLayout {
val text = createRef()
val guideline = createGuidelineFromStart(0.5f)
Text(
"This is a very very very very very very very long text",
Modifier.constrainAs(text) {
linkTo(guideline, parent.end)
width = Dimension.preferredWrapContent
}
)
}
}

Codelab_Jetpack_Compose_layout_033

Dimension을 사용하여 그림과 같이 줄바꿈을 수행하였다. Dimension 동작에 대해 정리해보자.

  • preferredWrapContent : 레이아웃이 해당 레이아웃 측정 기준의 constraint를 적용하는 wrap 컨텐츠라고 설명한다. 즉, 레이아웃을 내부 공간에 맞춰서 wrap content로 설정함을 의미한다.
  • wrapContent : 제약 조건(constriant)에서 허용하지 않더라도 내부 공간과는 무관하게 컨텐츠를 감쌀 수 있는만큼의 크기를 갖는다.
  • fillToConstraints : constraint에 선언된 값만큼을 채운다.
  • preferredValue : 레이아웃이 constraint 내부 공간에서 선언된 값(dp)만큼의 크기를 갖는다.
  • value : 레이아웃에 constraint와는 상관없이 고정 값(dp)을 적용한다.

특정 Dimension 동작은 강제 적용될 수 있다.

width = Dimension.preferredWrapContent.atLeast(100.dp)

분리된(Decoupled) API

앞에서는 ContraintLayout 내부의 Composable들에 대해 각기 다른 컴포저블과의 관계를 직접 자신들의 Modifier로 정의하였다. 좀 더 나아가서 컴포저블에 참조 정도만 입력하고 참조의 생성 및 constraint 설정은 다른 곳에서 이루어지게끔 작성할 수 있다.

@Composable
fun DecoupledConstraintLayout() {
BoxWithConstraints {
val constraints = if (maxWidth < maxHeight) {
decoupledConstraints(margin = 16.dp) // Portrait constraints
} else {
decoupledConstraints(margin = 32.dp) // Landscape constraints
}
ConstraintLayout(constraints) {
Button(
onClick = { /* Do something */ },
modifier = Modifier.layoutId("button")
) {
Text("Button")
}
Text("Text", Modifier.layoutId("text"))
}
}
}
private fun decoupledConstraints(margin: Dp): ConstraintSet {
return ConstraintSet {
val button = createRefFor("button")
val text = createRefFor("text")
constrain(button) {
top.linkTo(parent.top, margin= margin)
}
constrain(text) {
top.linkTo(button.bottom, margin)
}
}
}

DecoupledConstraintLayout 컴포저블 함수에서 decoupledConstraints함수를 사용하여 레퍼런스를 생성하고 각 레퍼런스에 할당될 constraint들을 정의한다. 이후 ConstraintLayout는 앞서 생성했던 constraint를 매개변수로 받고 내부에 정의한 컴포저블에 레퍼런스를 할당하여 컴포저블에 constraint가 적용될 수 있도록 한다.

참고자료

  1. Google Codelab - Jetpack Compose Layout #10 제약 조건 레이아웃

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

Jetpack Compose의 레이아웃 코드랩에서 실습한 내용들을 정리합니다. 레이아웃 컴포저블을 사용하여 좀 더 고도화된 커스텀 레이아웃을 정의합니다. 코드랩 실습에 요구되는 시간은 대략 1시간 정도로 학습 목적을 고려하면 대략 2 - 3시간 정도를 투자해야 합니다. 분량이 너무 많아 챕터 별로 분할하여 업로드합니다.

고도화된 커스텀 레이아웃 - StaggeredGrid

Layout 컴포저블을 사용하여 기본적인 컴포저블을 재현해봤다. 좀 더 발전시켜서 복잡한 커스텀 레이아웃을 작성해보자. 코드랩에서 제시하는 커스텀 레이아웃은 Material Design 예제 프로젝트 - Owl의 지그재그형 그리드다.

Codelab_Jetpack_Compose_layout_026

가운데 배치된 지그재그형 그리드는 통상적인 Column과 Row로 구현하기에는 조금 번잡한 면이 있다. 받아오는 데이터 구조가 Column 단위로 쪼개서 Row로 나열할 수 있도록 구성되어 있으면 가능하겠으나 가급적 한 번에 모든 구획을 채워지게끔 만들고 싶다.

커스텀 레이아웃을 정의하면 데이터 구조에 대해 고민할 필요 없이 모든 항목의 높이를 측정하고 제한하여 배치할 수 있다. 재사용성을 위해 row는 커스텀할 수 있도록 매개변수로 전달하자.

@Composable
fun StaggeredGrid(
modifier: Modifier = Modifier,
rows: Int = 3,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 주어진 제약조건에 따라 자식 요소를 측정하고 배치
}
}

커스텀 레이아웃이지만 앞서 작성했듯이 Layout 컴포저블 선언 - 자식 요소 측정 - 자식 요소 배치의 구조를 띌 것이다.

이제껏 그래왔듯이, 추가적인 제약조건 정의는 하지 않고 하위 요소는 한 번만 측정할 수 있음을 유념하자.

Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 각 행의 너비를 추적 - 초기값은 0
val rowWidths = IntArray(rows) { 0 }
// 각 열의 최대 높이를 추적 - 초기값은 0
val rowHeights = IntArray(rows) { 0 }
// 자식 요소에 추가 제약 없이 주어진 제약조건대로 측정
// 측정된 자식들이 담긴 리스트 반환
val placeables = measurables.mapIndexed { index, measurable ->
// 각 자식 요소 측정
val placeable = measurable.measure(constraints)
// 각 행에 대해 너비와 최대 높이 추적
// item이 배치될 행 좌표를 index % 최대 행 개수로 계산하여 균등하게 배분
val row = index % rows
// 배치 후 너비 반영
rowWidths[row] += placeable.width
// 배치 후 높이 반영(현재 높이와 배치한 아이템의 높이 비교하여 최대값 반영)
rowHeights[row] = Math.max(rowHeights[row], placeable.height)
// 자식 요소 배치
placeable
}
...
}

위 코드에서 주석 처리한 설명처럼 각 행의 너비와 높이를 추적하여 반영한다.

하위 요소의 배치 로직을 작성하였으므로 화면에 배치하기 전 전체 그리드의 너비와 높이를 결정해야 한다.

Layout(
content = content,
modifier = modifier
) { measurables, constraints ->
...
// 그리드의 너비는 가장 넓은 너비의 행과 동일하다.
val width = rowWidths.maxOrNull()
?.coerceIn(constraints.minWidth.rangeTo(constraints.maxWidth)) ?: constraints.minWidth
// 그리드의 높이는 각 행의 가장 기다란 요소의 높이들을 더한 값이다.
// 높이 제약 조건으로 강제하였음
val height = rowHeights.sumOf { it }
.coerceIn(constraints.minHeight.rangeTo(constraints.maxHeight))
// 각 행의 Y값은 이전 행의 높이 누적값이다.
val rowY = IntArray(rows) { 0 }
for (i in 1 until rows) {
rowY[i] = rowY[i-1] + rowHeights[i-1]
}
...
}

하위 요소의 배치 로직에 따라 각 행의 너비와 최대 높이는 확보하였으므로 해당 값들을 기반으로 그리드 레이아웃의 전체 너비와 높이를 결정한다.

마지막으로 하위 요소들을 placeable.placeRelative(x, y)를 호출하여 화면에 하위 요소를 배치한다.

Layout(
content = content,
modifier = modifier
) { measurables, constraints ->
...
// 부모 레이아웃의 사이즈 지정
layout(width, height) {
// row마다 아이템이 배치될 X좌표
val rowX = IntArray(rows) { 0 }
placeables.forEachIndexed { index, placeable ->
val row = index % rows
placeable.placeRelative(
x = rowX[row],
y = rowY[row]
)
rowX[row] += placeable.width
}
}
}

rowX 변수는 하위 아이템을 배치하면서 변경되기에 layout 함수 scope에 선언되었다. 이로써 rowX, rowY 변수를 사용하여 x, y 변수를 추적한다.

StaggeredGrid 아이템 정의

@Composable
fun Chip(modifier: Modifier = Modifier, text: String) {
Card(
modifier = modifier,
border = BorderStroke(color = Color.Black, width = Dp.Hairline),
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier.padding(start = 8.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.size(16.dp, 16.dp)
.background(color = MaterialTheme.colors.secondary)
)
Spacer(Modifier.width(4.dp))
Text(text = text)
}
}
}
@Preview
@Composable
fun ChipPreview() {
LayoutsCodelabTheme {
Chip(text = "Hi there")
}
}

앞서 정의한 커스텀 레이아웃인 StaggeredGrid 레이아웃에 넣을 아이템 레이아웃을 정의해야 한다. 좌측에 placeholder를 정의하고 우측에 파라미터로 받은 텍스트를 표시한다. 코드대로 작성한 경우 미리보기는 다음과 같다.

Codelab_Jetpack_Compose_layout_027

StaggeredGrid의 전체 코드는 다음과 같다.

@Composable
fun StaggeredGrid(
modifier: Modifier = Modifier,
rows: Int = 3,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 각 행의 너비를 추적 - 초기값은 0
val rowWidths = IntArray(rows) { 0 }
// 각 열의 최대 높이를 추적 - 초기값은 0
val rowHeights = IntArray(rows) { 0 }
// 자식 요소에 추가 제약 없이 주어진 제약조건대로 측정
// 측정된 자식들이 담긴 리스트 반환
val placeables = measurables.mapIndexed { index, measurable ->
// 각 자식 요소 측정
val placeable = measurable.measure(constraints)
// 각 행에 대해 너비와 최대 높이 추적
// item이 배치될 행 좌표를 index % 최대 행 개수로 계산하여 균등하게 배분
val row = index % rows
// 배치 후 너비 반영
rowWidths[row] += placeable.width
// 배치 후 높이 반영(현재 높이와 배치한 아이템의 높이 비교하여 최대값 반영)
rowHeights[row] = Math.max(rowHeights[row], placeable.height)
// 자식 요소 배치
placeable
}
// 그리드의 너비는 가장 넓은 너비의 행과 동일하다.
val width = rowWidths.maxOrNull()
?.coerceIn(constraints.minWidth.rangeTo(constraints.maxWidth)) ?: constraints.minWidth
// 그리드의 높이는 각 행의 가장 기다란 요소의 높이들을 더한 값이다.
// 높이 제약 조건으로 강제하였음
val height = rowHeights.sumOf { it }
.coerceIn(constraints.minHeight.rangeTo(constraints.maxHeight))
// 각 행의 Y값은 이전 행의 높이 누적값이다.
val rowY = IntArray(rows) { 0 }
for (i in 1 until rows) {
rowY[i] = rowY[i-1] + rowHeights[i-1]
}
// 부모 레이아웃의 사이즈 지정
layout(width, height) {
// row마다 아이템이 배치될 X좌표
val rowX = IntArray(rows) { 0 }
placeables.forEachIndexed { index, placeable ->
val row = index % rows
placeable.placeRelative(
x = rowX[row],
y = rowY[row]
)
rowX[row] += placeable.width
}
}
}
}

StaggeredGrid에 들어갈 텍스트 아이템들을 하드코딩된 배열로 작성하고 실제로 호출해보자.

val topics = listOf(
"Arts & Crafts", "Beauty", "Books", "Business", "Comics", "Culinary",
"Design", "Fashion", "Film", "History", "Maths", "Music", "People", "Philosophy",
"Religion", "Social sciences", "Technology", "TV", "Writing"
)
@Preview
@Composable
fun LayoutCodelabPreview() {
LayoutsCodelab()
}
@Composable
fun LayoutsCodelab() {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = "LayoutsCodelab")
},
actions = {
IconButton(onClick = { /* doSomething() */ }) {
Icon(Icons.Filled.Favorite, contentDescription = null)
}
}
)
}
) { innerPadding ->
BodyContent()
}
}
@Composable
fun BodyContent(modifier: Modifier = Modifier) {
StaggeredGrid(modifier = modifier) {
for (topic in topics) {
Chip(modifier = Modifier.padding(8.dp), text = topic)
}
}
}

Codelab_Jetpack_Compose_layout_028

허나 이렇게 사용하면 preview에서 볼 수 있듯이 화면 너비를 넘어가는 경우 가로로 스크롤할 수 없다. 가로 스크롤 속성을 부여할 수 있는 Row로 래핑하고 BodyContent 컴포저블 함수의 Modifier 파라미터를 StaggeredGrid가 아니라 Row 컴포저블에 전달하여 스크롤 가능하게 구현할 수 있다.

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
Row(modifier = modifier.horizontalScroll(rememberScrollState())) {
StaggeredGrid {
for (topic in topics) {
Chip(modifier = Modifier.padding(8.dp), text = topic)
}
}
}
}

참고자료

  1. Google Codelab - Jetpack Compose Layout #8 복잡한 맞춤 레이아웃

+ Recent posts