Jetpack Compose Text 1편

 이번에는 Compose의 텍스트에 관해 정리를 해봤습니다.

- Compose는 텍스트를 표시하고 사용자 입력을 처리하는 기본 구조인 BasicText 및 BasicTextField를 제공함

- 상위 수준에서 Compose는 Text 및 TextField를 제공하며 머티리얼 디자인를 따름

(위 Composable은 디자인과 분위기가 Android 사용자에게 적합하며 쉽게 커스텀 할 수 있는 옵션이 포함되어 있으므로 사용을 권장함)


1. 기본 Text 표시

텍스트를 표시하는 가장 기본적인 방법은 Text Composable을 사용하는 것입니다.

@Composable
fun SimpleText() {
    Text("Hello World")
    
    //문자열 리소스를 사용하는 예제
    Text(stringResource(R.string.hello_world))
}


2. Text Style 지정

Text Composable에는 콘텐츠의 스타일을 지정할 수 있는 여러 매개변수가 있습니다.

이 매개변수 중 하나를 설정할 때마다 전체 텍스트 값에 스타일이 적용됩니다. 

- color, fontSize, fontStyle, fontWeight, textAlign


언어 텍스트 방향에 따라 Text 이동하는 TextAlign.Start와 TextAlign.End를 사용하는 것을 권장함

(TextAlign.Left, TextAlign.Right 은 가급적 제한적 사용)

@Composable
fun SimpleText() {
    
    //텍스트 색상 변경
    Text("Hello World", color = Color.Blue)
    
    //텍스트 크기 변경
    Text("Hello World", fontSize = 30.sp)
    
    //텍스트를 기울임꼴로 설정
    Text("Hello World", fontStyle = FontStyle.Italic)
    
    //텍스트를 굵게 표시
    Text("Hello World", fontWeight = FontWeight.Bold)
    
    //텍스트 정렬
    Text("Hello World", textAlign = TextAlign.Center, modifier = Modifier.width(150.dp))
}


3. Text font

- Text의 fontFamily 매개변수를 사용하면 Composable에 사용되는 글꼴을 관리할 수 있음

- 기본적으로 Serif, Sans-serif, 고정폭 및 필기체 글꼴 모음이 포함

- fontFamily 속성을 사용하여 res/fonts 폴더에 정의된 맞춤 font로 작업할 수 있음



@Composable
fun SimpleText() {
    
    Text("Hello World", fontFamily = FontFamily.Serif)
    
    Text("Hello World", fontFamily = FontFamily.SansSerif)
}



- fontFamily를 Text 컴포저블에 전달할 수 있습니다.

- fontFamily에 수동으로 fontWeight를 설정하여 텍스트에 적합한 가중치를 선택할 수 있습니다.

val firaSansFamily = FontFamily(
    Font(R.font.firasans_light, FontWeight.Light),
    Font(R.font.firasans_regular, FontWeight.Normal),
    Font(R.font.firasans_italic, FontWeight.Normal, FontStyle.Italic),
    Font(R.font.firasans_medium, FontWeight.Medium),
    Font(R.font.firasans_bold, FontWeight.Bold)
)

Column {
    Text(..., fontFamily = firaSansFamily, fontWeight = FontWeight.Light)
    Text(..., fontFamily = firaSansFamily, fontWeight = FontWeight.Normal)
    Text(..., fontFamily = firaSansFamily, fontWeight = FontWeight.Normal, fontStyle = FontStyle.Italic)
    Text(..., fontFamily = firaSansFamily, fontWeight = FontWeight.Medium)
    Text(..., fontFamily = firaSansFamily, fontWeight = FontWeight.Bold)
}



4. Text Composable 내의 여러 스타일 설정 

동일한 Text 컴포저블 내에서 여러 스타일을 설정하려면 AnnotatedString을 사용해야 합니다.

AnnotatedString은 다음 항목이 포함된 데이터 클래스입니다.


- Text 값

- SpanStyleRange의 List: 텍스트 값 내 위치 범위가 포함되는 인라인 스타일 지정과 동일함

- ParagraphStyleRange의 List: 텍스트 정렬, 텍스트 방향, 행 간격, 텍스트 들여쓰기 스타일 지정


TextStyle은 Text 컴포저블에서 사용되며

SpanStyle 및 ParagraphStyle은 AnnotatedString에서 사용됩니다.


SpanStyle과 ParagraphStyle의 차이점은

ParagraphStyle은 전체 단락에 적용할 수 있고,  SpanStyle은 문자 수준에서 적용할 수 있다는 것입니다.

@Composable
fun MultipleStylesInText() {
    Text(buildAnnotatedString {
        withStyle(style = SpanStyle(color = Color.Blue)) {
            append("H")
        }
        append("ello ")

        withStyle(style = SpanStyle(fontWeight = FontWeight.Bold, color = Color.Red)) {
            append("W")
        }
        append("orld")
    })
}



@Composable
fun ParagraphStyle() {
    Text(buildAnnotatedString {
        withStyle(style = ParagraphStyle(lineHeight = 30.sp)) {
        withStyle(style = SpanStyle(color = Color.Blue)) {
            append("Hello\n")
        }
        withStyle(style = SpanStyle(fontWeight = FontWeight.Bold,
            color = Color.Red)) {
            append("World\n")
        }
        append("Compose")
        }
    })
}




5. Text 최대 줄 수

- Text Composable에 표시되는 줄 수를 제한하려면 maxLines 매개변수를 설정

@Composable
fun LongText() {
    Text("hello ".repeat(50), maxLines = 2)
}


6. Text 오버플로

- 긴 텍스트를 제한할 때 표시된 텍스트가 잘린 경우에만 표시되는 텍스트 오버플로를 표시할 수 있음

- textOverflow 매개변수를 설정하면됨

@Composable
fun OverflowedText() {
    Text("Hello Compose ".repeat(50), maxLines = 2, overflow = TextOverflow.Ellipsis)
}


**정리

- Compose 의 Text Composable은 기본적으로 머티리얼 디자인 가이드에 따라 구현이 됨

- Text Composable의 매개변수로 스타일을 지정할 수 있음 크기, 색깔, 기울기, 정렬, 굵기,최대 줄수, 오버플로우 등등

- RTL, LTR 설정 컨텍스트에 따라 정렬이 자연적으로 달라지게 되어있음 (Left,Right 보다 Start,End 사용을 권장)

- font도 fontFamily parameter로 쉽게 설정가능, fontWeight parameter로 가중치 수동으로 설정가능

- Text Composable 내의 글자마다 설정을 다르게 하고 싶으면 AnnotatedString를 사용

=> ParagraphStyle은 전체단락 , SpanStyle은 문자 수준 적용


Jetpack Navigation + BottomNavigationBar 구현하기

1. Jetpack Navigation


사실 Jetpack Navigation에 대해서는 이전에도 한번 포스트를 쓴 적이 있다.

Navigation은 크게 3가지 요소로 이루어져 있다.


NavController

- 대상(즉, 앱의 화면) 간 이동을 담당하는데, 탐색 동작을 처리하는 역할

- 대상을 탐색하고 딥 링크를 처리하며 대상 백 스택을 관리하는 등의 여러 메소드를 제공


NavGraph

- 이동할 컴포저블 대상의 매핑을 담당

- 앱 내의 화면 연결을 보여주는 일종의 지도 역할을 한다.


NavHost

- 현재 탐색 대상(navigation destination)이 포함된 UI 요소

- NavGraph의 현재 대상을 표시하는 컨테이너 역할을 하는 컴포저블


2. BottomNavigationBar 구현하기

이제 위에서 설명한 Navigation과 Compose를 활용해 BottomNavigationBar를 구현해보자.

gradle에 라이브러리 추가

dependencies {
    implementation("androidx.navigation:navigation-compose:2.7.7")
}


Bottom Navigation으로 이동할 화면 생성

필자는 여기서 Home, Rating, Profile 3개의 화면을 만들어주었다.


@Composable
fun HomeScreen() {
    Text(text = "Home")
}

@Composable
fun RatingScreen() {
    Text(text = "Rating")
}

@Composable
fun ProfileScreen() {
    Text(text = "Profile")
}


BottomNavItem 생성.

Bottom Navigation으로 이동할 item 객체 클래스 BottomNavItem 클래스를 생성하고,

그 자식으로 Home, Rating, Profile 을 추가한다.

sealed class BottomNavItem(
    @StringRes val title: Int,
    @DrawableRes val icon: Int,
    val screenRoute: String
) {
    object Home : BottomNavItem(R.string.home, R.drawable.ic_home, NavScreens.Home.name)
    object Rating : BottomNavItem(R.string.rating, R.drawable.ic_rating, NavScreens.Rating.name)
    object Profile : BottomNavItem(R.string.profile, R.drawable.ic_profile, NavScreens.Profile.name)
}


NavHost, NavGraph 생성.

NavHost 함수의 NavGraphBuilder 를 통해서 NavGraph를 생성할 수 있다.

@Composable
private fun MyNavHost(
    modifier: Modifier = Modifier,
    navController: NavHostController,
    startDestination: String
) {
    NavHost(
        modifier = modifier,
        navController = navController,
        startDestination = startDestination
    ) {
        ...

        composable(route = NavScreens.Home.name) {
            HomeScreen(
                onSearchClicked = { navController.navigate(NavScreens.Search.name) }
            )
        }

        composable(route = NavScreens.Rating.name) {
            RatingScreen()
        }

        composable(route = NavScreens.Profile.name) {
            ProfileScreen()
        }

        composable(route = NavScreens.Search.name) {
            SearchScreen()
        }
    }
}


Bottom Navigation 생성
@Composable
private fun MyBottomNavigation(
    modifier: Modifier = Modifier,
    navController: NavHostController
) {
    val navBackStackEntry by navController.currentBackStackEntryAsState()
    val currentRoute = navBackStackEntry?.destination?.route
    val items = listOf(
        BottomNavItem.Home,
        BottomNavItem.Rating,
        BottomNavItem.Profile
    )

    NavigationBar {
        items.forEach { item ->
            NavigationBarItem(
                selected = currentRoute == item.screenRoute,
                label = {
                    Text(
                        text = stringResource(id = item.title),
                        style = TextStyle(
                            fontSize = 12.sp
                        )
                    )
                },
                icon = {
                    Icon(
                        painter = painterResource(id = item.icon),
                        contentDescription = stringResource(id = item.title)
                    )
                },
                onClick = {
                    navController.navigate(item.screenRoute) {
                        navController.graph.startDestinationRoute?.let {
                            popUpTo(it) { saveState = true }
                        }
                        launchSingleTop = true
                        restoreState = true
                    }
                },
            )
        }
    }

}


BottomNavigationBar를 그리는 앱 화면 구성

@Composable
fun MyApp() {
    val navController = rememberNavController()

    Scaffold(
        modifier = Modifier.fillMaxSize(),
        topBar = {
            ...
        },
        bottomBar = {
            MyBottomNavigation(navController = navController)
        }
    ) {
        Box(modifier = Modifier.padding(it)) {
            MyNavHost(
                navController = navController,
                startDestination = NavScreens.Home.name
            )
        }
    }
}



Jetpack Compose ConstraintLayout

Compose에 대한 내용을 언급하던 중에 ConstraintLayout 이란 항목이 갑자기 튀어나옵니다. 

한껏 Column, Row, Box란 view group 역할을 하는 composable을 한껏 제공해 주고 나서 말이죠. 

게다가 ConstraintLayout은 기존 View group 방식에서 view group의 중첩, 다시 말해 layout의 depth가 깊어지는 걸 막기 위해 2017년에 새롭게 등장한 layout입니다.

CustomLayout에서 이미 봤듯이 Compose에서는 measure가 한 번만 가능하기 때문에 View의 depth가 깊어지더라도 문제가 발생하지 않습니다. 

다만 Compose에서의 ConstraintLayout은 뷰의 구조가 복잡할 때, 여러 view들 간의 상호관계에 따른 배치를 좀 더 쉽게 가능하도록 하는 목적으로 사용됩니다.

추후 설명할 Compose의 constraint의 사용법은 기존 view system에서 사용하던 ConstraintLayout과 거의 동일합니다. (코드로 표현하는 방법이 다를 뿐) 다만 목적은 "기존의 view system에서 사용된 것과는 다르다" 정도만 이해하고 있으면 이번 포스트팅은 어렵지 않게 클리어 가능할 것으로 생각됩니다.

이 글은 Android developer 공식 사이트에서 제공하는 문서를 기반으로 의역, 번역하였습니다.



Gradle dependency

Constraint layout을 compose에서 사용하기 위해서는  따로 dependency가 필요합니다. 여기서 주의해야 할 점은 기본 compose의 버전을 따라가지 않는다는 겁니다.

implementation "androidx.constraintlayout:constraintlayout-compose:1.0.1"


현재 최신 compose 버전은 1.6.7이며, 이 constraintlayout (1.0.1)을 쓰려면 kotlin은 2.0.0을 써야 합니다

정리하면 기본 compose 버전과는 다르게 진행된다는 것과 constraintlayout을 사용하기 위해서 kotlin 버전도 호환성 있는 버전으로 맞춰서 사용해야 한다는 점을 유념해 두어야 합니다.


ConstraintLayout의 기본적인 사용

Constraint layout에서 view들 간의 관계는 ID로 표현합니다. 

Compose에서는 ID를 createRef(), createRefs()로 생성합니다.

- createRefs() 또는 createRef()를 사용하여, ConstraintLayout 내에서 각 컴포저블에 대한 참조를 선언

- 이 과정은 xml에서 ConstraintLayout내에서의 참조를 위해 각 레이아웃에 id를 부여하는 작업과 유사


생성된 ID를 각 Compose에 부여하기 위 해어 constrainAs() modifier를 제공하며, lambda block안에서 다른 compose와의 관계를 정의합니다.

lambda block에서 관계를 정의할 때는 linkTo()를 이용합니다.

Compose에서도 parent를 사용할 수 있으며 ConstraintLayout을 나타냅니다.


이제 버튼을 하나 만들고, 그 아래 text를 배치하는 간단한 형태를 구현해 보겠습니다.

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {
        // Creates references for the two composables
        // in the ConstraintLayout's body
        val (button1, 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)
        })

    }
}


먼저 각 compose에서 사용할 reference값 (기존 view system에서는 ID)를 생성합니다.

constrainAs()로 reference를 부여한 후에 해당 block안에서 linkTo로 관계를 정의합니다. 

이미 기존 view system의 constraint layout에 익숙한 개발자라면 표현방법이 다를 뿐 동일한 형태로 사용한다는 걸 알 수 있습니다. 따라서 이해하기가 다른 compose 섹션보다는 쉽습니다.


여기서 Text버튼이 쏠려 있으니 가운데 정렬을 위해서 centerHorizontallyTo()를 이용해 보겠습니다. 

이 function은 start와 end를 각각 parent의 양쪽 끝 edge로 선언하는 것과 동일한 효과를 갖습니다.

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {
        ...

        Text("Text", Modifier.constrainAs(text) {
            top.linkTo(button1.bottom, margin = 16.dp)
            // Centers Text horizontally in the ConstraintLayout
            centerHorizontallyTo(parent)
        })

    }
}






parent의 중간에 위치하도록 Text를 설정했지만 결국 button1 기준으로 중간에 위치한것처럽 보입니다. 

이는 constratinlayout 자체가 기본적으로 본인이 가진 contents를 wrap 한 사이즈를 사용하기 때문입니다. 

다르게 말해 자기가 가진 자식 composables을 표현할 수 있는 최소한의 크기만을 사용합니다.

물론 전체 화면으로 확장해서 사용하기 위해 이미 다른 포스팅에서 여러 번 사용했던 modifier의 fillMaxSize 또는 size를 이용하면 됩니다.



GuideLine

가이드라인은 컴포저블을 위치하게 할 수 있는 보이지 않는 뷰입니다.

가이드라인은 float, offset, dp를 사용하여 크기/위치를 설정할 수 있습니다.


아래와 같은 방식으로 가이드라인을 선언 할 수 있습니다.


val guideLineFromTop5f = createGuidelineFromTop(0.5f)

val guideLineFromTop20dp = createGuidelineFromTop(20.dp)

val guideLineFromEnd20dp = createGuidelineFromEnd(20.dp)

각각, 부모의 Top에서 50%, 20dp, End에서 20dp 만큼 떨어진 부분에 가이드라인이 생성됩니다.


선언 된 가이드라인은 다른 컴포저블들을 ConstraintLayout 내에서 위치시키는데 이용될 수 있습니다.

@Composable
fun GuideConstraintLayoutContent() {
    ConstraintLayout (modifier = Modifier.size(200.dp)) {
        val (text1, text2) = createRefs()

        val guideLineFromTop5f = createGuidelineFromTop(0.5f)
        val guideLineFromTop20dp = createGuidelineFromTop(20.dp)
        val guideLineFromEnd20dp = createGuidelineFromEnd(20.dp)

        Text(
            "50% from top",
            modifier = Modifier.constrainAs(text1) {
                top.linkTo(guideLineFromTop5f)
            }
        )

        Text(
            "Top 20dp End 20dp",
            modifier = Modifier.constrainAs(text2) {
                top.linkTo(guideLineFromTop20dp)
                end.linkTo(guideLineFromEnd20dp)
            }
        )

    }
}





Barrier

Barrier는 가이드라인과 같이 보이지 않는 뷰로서, 다른 컴포저블을 위치시키는데 도움을 줍니다.


하지만 가이드라인이 부모를 기준으로 위치했다면 Barrier는 다른 컴포넌트를 기준으로 유동적으로 위치하게 할 수 있습니다.


아래는 text1, text2중 더 end가 긴 쪽에 가이드라인을 선언하는 코드입니다.


val endBarrier = createEndBarrier(text1, text2)

End 대신 Top, Start, Bottom등을 넣어서 원하는 위치에 배리어를 생성할 수 있습니다.


이를 통해 아래와 같은 뷰를 만들 수 있습니다.

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {
        // Creates references for the three composables
        // in the ConstraintLayout's body
        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")
        }
    }
}



이번에는 reference를 세 개 만들고 버튼 두 개와 Text를 하나 추가 했습니다.

Button1의 위치를 기준으로 Text는 button1의 아래에 위치시키고, Text의 중간이 button1의 끝이 오는 부분에 위치시켰습니다. 추가적으로 button1과 text의 끝 부분에 barrier를 만들고 button2의 시작은 barrier부터 하도록 연결합니다.




barrier 또는 그 이외의 다른 helper들은 constraintAs 영역 안에서 생성할 수 없습니다. 

따라서 위 샘플 코드처럼 constraintAs의 lambda 영역 밖에서 선언해야 합니다.



Chain

기존 xml에서의 ConstraintLayout 안에서 여러 레이아웃을 app:layout_constraintVertical_chainStyle 등의 코드를 통해 체이닝하여 사용하였습니다.

이는 Jetpack Compose에서도 마찬가지로 사용 가능합니다.

createHorizontalChain(button1, button2, chainStyle = ChainStyle.Packed)

우선, createHorizontalChain()을 사용해 체인을 선언해야합니다. createVerticalChain()을 통해 세로로 체이닝 할 수도 있습니다.

chainStyle는 각 컴포저블을 어떤 형태로 체이닝할지 결정하는 요소입니다.

특히, ChainStyle.Packed()는 bias 설정을 통해 어느 한 쪽으로 쏠리게 할 수 있습니다.

아래는 전체 코드이며, ChainStyle에 따라 위의 결과 중 선택한 style에 따른 결과를 얻을 수 있습니다.

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout (modifier = Modifier
        .fillMaxWidth()
        .height(200.dp)) {
        val (button1, button2, text) = createRefs()

        createHorizontalChain(button1, button2, chainStyle = ChainStyle.Packed) // SpreadInside, Spread, Packed

        Button(
            onClick = { },
            modifier = Modifier.padding(8.dp).constrainAs(button1) {
                top.linkTo(parent.top, margin = 8.dp)
            }
        ) {
            Text("Button 1")
        }

        Button(
            onClick = { },
            modifier = Modifier.padding(8.dp).constrainAs(button2) {
                top.linkTo(parent.top, margin = 8.dp)
            }
        ) {
            Text("Button 2")
        }

        Text(
            "Text",
            modifier = Modifier.constrainAs(text) {
                top.linkTo(button1.bottom, margin = 16.dp)
                centerHorizontallyTo(parent)
            }
        )
    }
}





Jetpack Compose LazyColumn / LazyRow (=RecyclerView)

이번 포스팅은 Compose의 리스팅 기능을 살펴보고자 한다.


기존 Xml방식은 List를 주로 RecyclerView 혹은 ListView로 많이 구현을 했을것이다.

Compose에서는 List를 어떻게 구현해야할지 살펴보겠습니다.


기본적인 Column 또는 Row를 사용하여 각 아이템의 콘텐츠를 표시할 수 있음

verticalScroll() Modifier를 사용하여 Column을 스크롤 가능하게 만들 수 있습니다. 

하지만 아이템 갯수만큼 UI가 미리 만들어져 있기 때문에 많은 size의 아이템을 표시해야 하는 경우 성능문제가 발생할 수 있음

@Composable
fun MessageList(messages: List<Message>) {
    Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
        messages.forEach { message ->
            MessageRow(message)
        }
    }
}


안드로이드 Jetpack Compose의 LazyColumn를 이용한 RecyclerView 구현은 기존 XML 방식에 비해서 코드가 간결하여 간단하게 개발할 수 있다.

또한, 아이템 간의 간격을 추가할 때도 Arrangement.spacedBy() 함수를 통해 설정할 수 있으며 별도의 Adapter 클래스가 필요하지 않은 것도 편하게 느껴집니다.

RecyclerView를 만들기 위해선 ViewHolder, Adapter 클래스도 만들어주고 또 xml에서 각 아이템의 레이아웃도 만들어줘야하고 할 게 굉장히 많았는데, Compose의 LazyColumn, LazyRow를 이용해서 구현하니 비교도 안될 정도로 간단하게 구현할 수 있었다.

LazyColumn, LazyRow는 기존의 RecyclerView와 동일하게 리스트에 속한 모든 View를 한번에 그리지 않고 스크롤하면서 화면에 보여지게 될 때만 그리게 함으로써 리소스 사용을 최적화하기 위한 용도로 만들어졌다.

Compose에서 제공하는 LazyRow를 사용하면 가로 방향으로 무한 스크롤이 되는 RecyclerView를 상당히 간단하게 구현할 수 있게 됐습니다.


- LazyColumn은 세로로 스크롤되는 목록 생성

- LazyRow는 가로로 스크롤되는 목록 생성

- LazyGrid (LazyVerticalGrid, LazyHorizontalGrid..)는 그리드 형태의 스크롤 되는 목록 생성




Android 공식 홈페이지 예제 코드로 RecyclerView의 기능을 얼마나 간단하게 짤 수 있는지 살펴보자.

LazyColumn (세로 스크롤 LazyList)

@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn {
        items(messages) { message ->
            MessageRow(message)
        }
    }
}


LazyColumn 에서 아이템을 추가하기 위해선 LazyList Scope 블록안에 추가하고자 하는 아이템을 넣으면 된다. (LazyRow, LazyGrid도 동일)


아이템을 추가하는 방법은 크게 2가지 케이스다

1. item 블록을 사용해 하나 추가

2. items 블록을 사용해 여러 개 추가

//item의 인덱스가 필요할 때는 itemsIndexed 를 사용하면 된다.
@Composable
fun MessageList(messages: List<Message>) {
    
    LazyRow(
        modifier = Modifier.fillMaxSize(),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        content = {
            itemsIndexed(messages) { index, message ->
                MessageRow(
                    id = index,
                    title = message.title,
                    contents = message.contents
                )
            }
        })
}



콘텐츠 가장자리 주변에 패딩을 추가해야 하는 경우가 있다.

지연 구성요소를 사용하면 일부 PaddingValues을 contentPadding 매개변수에 전달하여 이 작업을 지원할 수 있다.

LazyColumn(
    contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) {
    // ...
}


Arrangement.spacedBy()를 사용하면 아이템 마다 간격을 줄 수 있음

LazyColumn(
    verticalArrangement = Arrangement.spacedBy(4.dp),
) {
    // ...
}

LazyRow(
    horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
    // ...
}



고정 헤더(실험용)

(주의: 실험용 API는 향후 변경되거나 완전히 삭제될 수 있음)


'고정 헤더' 패턴은 그룹화된 데이터 목록을 표시할 때 유용함

LazyColumn이 있는 고정 헤더를 표시하려면 헤더 콘텐츠를 제공하는 실험용 stickyHeader() 함수를 사용

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ListWithHeader(items: List<Item>) {
    LazyColumn {
        stickyHeader {
            Header()
        }

        items(items) { item ->
            ItemRow(item)
        }
    }
}


흔히 사용하던 스티키 헤더 방식은 다음과 같은방식으로 가능

val grouped = contacts.groupBy { it.firstName[0] }

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ContactsList(grouped: Map<Char, List<Contact>>) {
    LazyColumn {
        grouped.forEach { (initial, contactsForInitial) ->
            stickyHeader {
                CharacterHeader(initial)
            }

            items(contactsForInitial) { contact ->
                ContactListItem(contact)
            }
        }
    }
}




그리드(실험용)

(주의: 실험용 API는 향후 변경되거나 완전히 삭제될 수 있음)


LazyVerticalGrid Composable은 아이템을 그리드로 표시하기 위한 실험용 지원 기능을 제공

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PhotoGrid(photos: List<Photo>) {
    LazyVerticalGrid(
        columns = GridCells.Fixed(3)
    ) {
        items(photos) { photo ->
            PhotoItem(photo)
        }
    }
}


스크롤 위치 제어

LazyListState는 스크롤 위치를 '즉시' 스냅하는 scrollToItem() 및 애니메이션을 사용하여 스크롤하는 animateScrollToItem() 함수를 통해 이 기능을 지원한다.

scrollToItem() 및 animateScrollToItem()은 모두 suspend 함수입니다. 

코루틴에서 호출해야 합니다.

@Composable
fun MessageList(messages: List<Message>) {
    val listState = rememberLazyListState()
    // Remember a CoroutineScope to be able to launch
    val coroutineScope = rememberCoroutineScope()

    LazyColumn(state = listState) {
        // ...
    }

    ScrollToTopButton(
        onClick = {
            coroutineScope.launch {
                // Animate scroll to the first item
                listState.animateScrollToItem(index = 0)
            }
        }
    )
}



페이징 (Paging)

아이템 양이 많으면 Paging 라이브러리를 사용해서 필요한 만큼 아이템을 불러와서 표시할 수 있음.

Paging 3.0 이상에서는 androidx.paging:paging-compose 라이브러리를 통해 Compose 지원 기능을 제공 

페이징된 콘텐츠 목록을 표시하려면 collectAsLazyPagingItems() 확장 함수를 사용한 다음,

반환된 LazyPagingItems를 LazyColumn의 items()에 전달하면 된다.

import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.items

@Composable
fun MessageList(pager: Pager<Int, Message>) {
    val lazyPagingItems = pager.flow.collectAsLazyPagingItems()

    LazyColumn {
        items(lazyPagingItems) {message ->
            message?.let {
                MessageRow(message)
            }
        }
    }
}





Jetpack Compose BackHandler (= onBackPressed) 뒤로가기

안드로이드에서 기존에 뒤로가기 버튼을 눌렀을 때 동작을 커스텀하려면 onBackPressed() 메소드를 오버라이딩 해야 한다.

Jetpack Compose 에서는 어떻게 구현해야 할까?

BackHandler 라는 내장함수를 사용하면 된다.

BackHandler의 enabled 프로퍼티는 BackHandler를 활성화할지 여부를 결정한다.(기본값은 true)


BackHandler를 사용하여 다음과 같은 작업을 수행할수 있다.

- 뒤로가기 버튼을 사용하여 이전화면으로 돌아가기 / 종료하기

- 뒤로가기 버튼을 사용하여 작업을 취소하기

- 뒤로가기 버튼을 사용하여 특정 기능을 활성화 또는 비활성화 하기


BackHandler(enabled = true, onBack= {
    // 뒤로가기 버튼이 눌렸을때 수행할 작업을 정의
    ...
})
// 뒤로가기 두 번 눌렀을 때 앱 종료
@Composable
fun BackOnPressed() {
    val context = LocalContext.current
    var backPressedState by remember { mutableStateOf(true) }
    var backPressedTime = 0L

    BackHandler(enabled = backPressedState) {
        if(System.currentTimeMillis() - backPressedTime <= 400L) {
            // 앱 종료
            (context as Activity).finish()
        } else {
            backPressedState = true
            Toast.makeText(context, "한 번 더 누르시면 앱이 종료됩니다.", Toast.LENGTH_SHORT).show()
        }
        backPressedTime = System.currentTimeMillis()
    }
}
//특정 화면(screen)에서만 종료팝업 띄우기
@Composable
fun BackOnPressed2() {
    val exitDialog = remember { mutableStateOf(false) }

    BackHandler(enabled = (currentRoute(navController) == Screen.Home.route)) {
        exitDialog.value = true
    }

    ...
    
    if (exitDialog.value) {
        ExitAlertDialog(navController, {
            exitDialog.value = it
        }, {
            activity?.finish()
        })

    }
}
@Composable
fun ExitAlertDialog(navController: NavController, cancel: (isOpen: Boolean) -> Unit,  ok: () -> Unit) {
    val openDialog = remember { mutableStateOf(true) }
    if (currentRoute(navController = navController) == Screen.Home.route && openDialog.value) {
        AlertDialog(
            onDismissRequest = {
            },
            // below line is use to display title of our dialog
            // box and we are setting text color to white.
            title = {
                Text(
                    text = stringResource(R.string.close_the_app),
                    fontWeight = FontWeight.Bold,
                    fontSize = 18.sp
                )
            },
            text = {
                Text(text = stringResource(R.string.do_you_want_to_exit_the_app), fontSize = 16.sp)
            },
            confirmButton = {
                TextButton(
                    onClick = {
                        openDialog.value = false
                        ok()
                    }) {
                    Text(
                        stringResource(R.string.yes),
                        fontWeight = FontWeight.Bold,
                        style = TextStyle(color = Color.Black)
                    )
                }
            },
            dismissButton = {
                TextButton(
                    onClick = {
                        openDialog.value = false
                        cancel(false)
                    }) {
                    Text(
                        stringResource(R.string.no),
                        fontWeight = FontWeight.Bold,
                        style = TextStyle(color = Color.Black)
                    )
                }
            },
        )
    }
}


Jetpack Compose Scaffold

 본 게시글은 Material3를 기준으로 작성되었습니다.


Scaffold

Material Design에서 사용되는 Composable function으로 다양한 구성요소와 기타 화면 요소를 제공합니다.

Material Design 가이드라인을 따르며, 앱의 상단 앱바, 하단 탐색 막대, 콘텐츠 영역 등을 설정할 수 있습니다.


Scaffold 구성 요소

TopAppBar

상단 앱바는 앱의 제목, 액션 버튼, 메뉴 등을 표시할 수 있는 영역입니다. TopAppBar를 사용하여 앱바의 콘텐츠와 동작을 정의할 수 있습니다.

BottomAppBar

하단 탐색 막대는 일반적으로 탐색 메뉴나 액션 버튼을 표시하는 데 사용됩니다. BottomAppBar를 사용하여 하단 막대의 콘텐츠와 동작을 정의할 수 있습니다.

FloatingActionButton

부동 액션 버튼은 일반적으로 앱에서 가장 중요한 작업을 표시하기 위해 사용됩니다. FloatingActionButton을 사용하여 부동 액션 버튼의 모양과 동작을 정의할 수 있습니다.

Content

콘텐츠 영역은 앱의 주요 내용을 표시하는 영역입니다. Content를 사용하여 콘텐츠 영역을 정의할 수 있으며, 보통은 Column이나 Row와 같은 다른 컴포넌트를 사용하여 구성합니다.


[Error] **Content padding parameter it is not used**

만약 Scaffold의 구성요소로 content{ } 부분에 에러가 발생하실 수 있습니다.

Compose 1.2.0부터는 Scaffold 내의 content에 padding value를 적용해야 합니다.


* floatingActionButtonPosition

- floating버튼의 위치를 지정할 수 있습니다. 가능한 위치는 Center와 End입니다.


* isFloatingActionButtonDocked

- floating버튼을 BottomBar의 중간에 걸치고자 한다면 true 값을, 아니면 false값을 사용하시면 됩니다.


* navigationIcon

- TopAppBar의 시작(왼쪽) 부분에 배치되는 아이콘입니다. 주로 메뉴 버튼 또는 네비게이션 아이콘으로 사용됩니다. navigationIcon은 TopAppBar에 단일 아이콘만 배치됩니다.


AppBar

상단 앱 바나 하단 앱 바(네비게이션 바)를 제공합니다.

Scaffold(
	topBar = {
    	TopAppBar { /* ... */ }
    },
    bottomBar = {
    	BottomAppBar { /* ... */ }
        // 혹은
        // NavigationBar() { /* ... */ }
    }
) {
	/* ... */
}



BottomAppBar

BottomAppBar를 사용하여 하단 막대의 콘텐츠와 동작을 정의할 수 있습니다.

Scaffold(
    bottomBar = {
        BottomAppBar(
            backgroundColor = Color.Blue
        ) {
            IconButton(onClick = {}) {
                Icon(Icons.Default.Favorite, contentDescription = "Favorite")
            }
            IconButton(onClick = {}) {
                Icon(Icons.Default.Add, contentDescription = "Add")
            }
        }
    }

) {
	/* ... */
}



FloatingActionButton

플로팅 작업 버튼을 제공합니다.

floatingActionButtonPosition 을 이용하여 가로 위치를 조정할 수 있습니다.

Scaffold(
	floatingActionButtonPosition = FabPosition.End,
	floatingActionButton = {
    	FloatingActionButton(onClick = { /* ... */ }) {
        	/* ... */
        }
    },
    
    // bottomBar가 있을 시 isFloatingActionButtonDocked 을 이용하여 겹치게 만들 수 있습니다.
    isFloatingActionButtonDocked = true,
    bottomBar = { /* ... */ }
) {
	/* ... */
}



Content

콘텐츠 영역은 앱의 주요 내용을 표시하는 영역입니다. Content를 사용하여 콘텐츠 영역을 정의할 수 있으며, 보통은 Column이나 Row와 같은 다른 컴포넌트를 사용하여 구성합니다.

Scaffold(
    content = {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .podding(it)
        ) { /* ... */ }
    }
) {
	/* ... */
}



Snackbar

val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }

Scaffold(
    snackbarHost = { SnackbarHost(snackbarHostState) },
	floatingActionButtonPosition = FabPosition.End,
	floatingActionButton = {
    	FloatingActionButton(onClick = { 
	        coroutineScope.launch {
    	    	snackbarHostState.showSnackbar("Show Snackbar")
        	}
        }) {
        	/* ... */
        }
    }
) {
    SnackbarHost(
        hostState = snackBarHostState,
        /* ... */
    )
}



Android 루팅(Rooting) 여부 체크

RootBeer의 간단한 버전으로 루팅여부를 체크해보자.

class RootChecker(private val context: Context) { private val rootFiles = arrayOf( "/system/app/Superuser.apk", "/sbin/su", "/system/bin/su", "/system/xbin/su", "/system/usr/we-need-root/", "/data/local/xbin/su", "/data/local/bin/su", "/system/sd/xbin/su", "/system/bin/failsafe/su", "/data/local/su", "/su/bin/su", "/su/bin", "/system/xbin/daemonsu" ) private val rootPackages = arrayOf( "com.devadvance.rootcloak", "com.devadvance.rootcloakplus", "com.koushikdutta.superuser", "com.thirdparty.superuser", "eu.chainfire.supersu", "de.robv.android.xposed.installer", "com.saurik.substrate", "com.zachspong.temprootremovejb", "com.amphoras.hidemyroot", "com.amphoras.hidemyrootadfree", "com.formyhm.hiderootPremium", "com.formyhm.hideroot", "com.noshufou.android.su", "com.noshufou.android.su.elite", "com.yellowes.su", "com.topjohnwu.magisk", "com.kingroot.kinguser", "com.kingo.root", "com.smedialink.oneclickroot", "com.zhiqupk.root.global", "com.alephzain.framaroot" ) private val runtime by lazy { Runtime.getRuntime() } fun isDeviceRooted(): Boolean { return checkRootFiles() || checkSUExist() || checkRootPackages() } private fun checkRootFiles(): Boolean { for (path in rootFiles) { try { if (File(path).exists()) { return true } } catch (e: RuntimeException) { } } return false } private fun checkSUExist(): Boolean { var process: Process? = null val su = arrayOf("/system/xbin/which", "su") try { process = runtime.exec(su) BufferedReader( InputStreamReader( process.inputStream, Charset.forName("UTF-8") ) ).use { reader -> return reader.readLine() != null } } catch (e: IOException) { } catch (e: Exception) { } finally { process?.destroy() } return false } private fun checkRootPackages(): Boolean { val pm = context.packageManager if (pm != null) { for (pkg in rootPackages) { try { pm.getPackageInfo(pkg, 0) return true } catch (ignored: PackageManager.NameNotFoundException) { // fine, package doesn't exist. } } } return false } }


Jetpack Compose 키보드작업처리

사용자가 수정 가능한 텍스트 구성요소(예:  TextField )에 포커스를 맞추면 기기에 하드웨어 키보드가 연결되어 있는 경우 모든 입력은 시스템에 의해 처리됩니다. 주요 이벤트를 처리하여 단축키를 제공할 수 있습니다. 주요 이벤트 Compose에서...