https://developer.android.com/jetpack/compose

 

Jetpack Compose  |  Android Developers

Jetpack Compose로 더 빠르게 더 나은 Android 앱 빌드 Jetpack Compose는 네이티브 UI를 빌드하기 위한 Android의 최신 도구 키트입니다. Jetpack Compose는 Android에서 UI 개발을 간소화하고 가속화합니다. 적은 수

developer.android.com

 

Build better apps faster with Jetpack Compose

잿팩 컴포즈와 함께 더 나은 앱을 빠르게 빌드

 

Jetpack Compose is Android’s modern toolkit for building native UI. It simplifies and accelerates UI development on Android. Quickly bring your app to life with less code, powerful tools, and intuitive Kotlin APIs.

잿팩 컴포즈는 네이티브 UI 빌드를 위한 안드로이드의 모던 툴킷입니다. 이것은 안드로이드 UI 개발을 간소화하고 가속화 합니다. 적은코드, 강력한 도구, 직관적인 코틀린 API를 사용하여 앱에 생명을 불어넣으세요.

 

 

선언형 프로그래밍에 대한 이해

안드로이드 잿팩 컴포즈를 사용하려면 선언형 프로그래밍에 대한 사전 지식이 필요한 것 같아 개인적으로 이해한 사항에 대해 정리해보았습니다.

선언형 프로그래밍
어떤 방법으로 해야하는지를 나타내기보다 무엇가 같은지를 설명하는 경우
명령형 프로그램은 알고리즘을 명시하고 목표는 명시하지 않는데 반해 선언형 프로그래밍은 목표를 명시하고 알고리즘을 명시하지 않는다.
함수형 프로그래밍 언어, 논리형 프로그래밍 언어, 혹은 제한형 프로그래밍 언어로 쓰인 경우에 선언형이라고 한다. 여기서 선언형 언어라는 것은 명령형 언어와 대비되는 이런 프로그래밍 언어들을 통칭하는 것이다.
-위키백과-

선언형 프로그래밍은 명렁형 프로그래밍과 반대되는 high-level 프로그래밍 컨셉입니다. 
일반적으로 DLS(도메인별 언어)과 쌍을 이루는 데이터베이스 및 구성 관리 소프트웨어에서 찾을 수 있습니다.
선언형 모델은 기능에대한 단계별 구현을 언어에서 사전에 갖춰진 능력에 의존합니다 
https://www.techtarget.com/searchitoperations/definition/declarative-programming -

명령형 프로그래밍

1. 1,2,3,4,5,6 이란 배열이 있음(변수선언)
2. 위의 수들중 짝수들만 담고싶어 빈배열 추가(변수선언)
3. 위 배열의 원소를 하나씩 골라 2로나눈 나머지가 0이라면 빈배열에 추가함(알고리즘)

선언형 프로그래밍

1. 배열 = 배열을 짝수로 필터

 

선언형 프로그래밍을 하려면 구현하려는기능이 우선 언어에서 제공해주는지 판단 할 수 있도록 사전에 상당한 학습 시간이 필요해보입니다.

선언적 프로그래밍에서는 루프 및 if/then 조건과 같은 일반적인 프로그래밍 구성은 지시적 이기때문에 존재하지 않습니다. 선언적 프로그래밍은 결과에만 집중합니다. (명령형은 과정에 집중)
선언적 프로그래밍은 명령형 프로그래밍으로 개발된 기능을 기반으로 하지만 개발자가 코드 설정의 복잡성보다는 문제 해결에 집중할 수 있도록 합니다.

 

여기까지 이해한 바로는

개발하는데 있어 어떠한 복잡한 비지니스 로직을 처리하는데에는 명령형 프로그래밍에 있어서는 비지니스로직의 단계별로 필요한 알고리즘을 사용하여 해결해나갑니다. 이로인해 비지니스 로직과 알고리즘이 섞여 가독성이 떨어지는고 단위테스트 및 유지보수가 어려워 질 수 있습니다. 그래서 선언형 프로그래밍으로 한다면 비지니스로직상에 알고리즘부분은 미리 명령형 프로그래밍으로 구현 후 다른곳에 두어 간단하게 호출만으로 해결 할 수 있게하여 비지니스 로직만을 한눈에 볼 수 있게 한다는 것이라고 이해하였습니다.

 

잿팩 컴포즈 크래인 선언형 코드 분석해보기

setContent {
    //1. 크래인 테마 적용
    CraneTheme {
        // 2. 윈도우 크기를 결정
        val widthSizeClass = calculateWindowSizeClass(this).widthSizeClass
        // 3. 네비게이션 컴포넌트를 사용 할 컨트롤러를 가져옵니다.
        val navController = rememberNavController()
        // 4. 첫화면을 Route.Home.route로 지정합니다.
        NavHost(navController = navController, startDestination = Routes.Home.route) {
            //Home 화면
            composable(Routes.Home.route) {
                // 메인 뷰모델 주입
                val mainViewModel = hiltViewModel<MainViewModel>()
                // 메인화면 선언
                MainScreen(
                    //화면 크기 설정
                    widthSize = widthSizeClass,
                    // 아이템을 클릭했을 때 상세화면으로 이동
                    onExploreItemClicked = {
                        launchDetailsActivity(context = this@MainActivity, item = it)
                    },
                    // 캘린더 화면으로 이동
                    onDateSelectionClicked = {
                        navController.navigate(Routes.Calendar.route)
                    },
                    //뷰모델 주입
                    mainViewModel = mainViewModel
                )
            }
            // Calendar 화면
            composable(Routes.Calendar.route) {
                // 뒤로갔을때 이동경로 설정?
                val parentEntry = remember {
                    navController.getBackStackEntry(Routes.Home.route)
                }
                // 뷰모델주입 및 뷰모델에 위에 선언한 이동경로 주입
                val parentViewModel = hiltViewModel<MainViewModel>(
                    parentEntry
                )
                // 달력화면 선언
                CalendarScreen(
                    // 뒤로가기 눌렀을때 뒤로 이동
                    onBackPressed = {
                        navController.popBackStack()
                    },
                    // 뷰모델 주입
                    mainViewModel = parentViewModel
                )
            }
        }
    }
}

잿팩 컴포즈 구현코드를 보며 느낀점

1. 잿팩컴포즈에서 제공하는 ui를 다루는 함수들에대한 충분한 학습이 필요한 것 같습니다.

2. 화면코드를 구현하다 이벤트 발생에 대한 처리등과 같은 비지니스로직에 대한 구현양이 많아진다면

    함수형 프로그래밍을 이용하여 한 줄로 호출하여 처리 할 수 있게 구현합니다.

3. 화면을 구현할 때는 내가 구현한 화면을 누군가가 가져가 사용한다고 생각하여, 내 화면에서 명확하게 구현해야하는 부분을 제외하고 필요한 기능들을 추상화 시켜, 내 화면을 사용하려고 할 때 이 추상화한부분을 다 정의해서 주입하고 사용하도록 합니다.

 

 

https://ko.wikipedia.org/wiki/%EC%84%A0%EC%96%B8%ED%98%95_%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D

https://www.techtarget.com/searchitoperations/definition/declarative-programming

https://github.com/android/compose-samples

https://medium.com/@vincentbacalso/imperative-vs-declarative-programming-f886d3b65595

-------------------

[안드로이드][Compose][2] 기존 프로젝트에 컴포즈 UI 적용하기 1 - 컴포즈 개발 환경 설정

목차
1. 컴포즈 환경 설정하기
 1.1 컴포즈 의존성 추가하기
 1.2 기존 xml레이아웃에 ComposeView 추가하기
 1.3 ComposeView에 컴포즈 UI 불러오기
 1.4 첫번째 오류: No virtual method setContent....
 1.5 두번째 오류 발생: This version of the Compose Compiler requires Kotlin version 1.4.32 but you appear...
 1.6 컴포즈 UI 요소 적용 성공
2. Preview 사용해보기
 2.1 기기에서 동작하는 프리뷰 기능은 SDK 31 이상을 타겟으로 해야합니다.
 2.2 org.gradle.api.tasks.TaskExecutionException: Execution failed for task 오류 발생
 2.3 Preview 적용

1. 컴포즈 환경 설정하기

이번에 적용해볼 목표는 Feed안에 컴포즈를 사용하여 뭐든 표시해보는 것 입니다. 사이트에서 예제를 하나 가져와봤습니다.

/** Compose test */
@Composable
fun MessageCard(name: String) {
    Text(text = "Hello $name!")
}

기존 프로젝트에 붙여넣어봤더니 아래와 같이 Composable와 Text에 빨간 글자로 오류가 발생했습니다.

1.1 컴포즈 의존성 추가하기

아래 사이트를 참고해 의존성을 추가해줬습니다.

dependencies {
    // Integration with activities
    implementation 'androidx.activity:activity-compose:1.4.0'
    // Compose Material Design
    implementation 'androidx.compose.material:material:1.1.1'
    // Animations
    implementation 'androidx.compose.animation:animation:1.1.1'
    // Tooling support (Previews, etc.)
    implementation 'androidx.compose.ui:ui-tooling:1.1.1'
    // Integration with ViewModels
    implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1'
    // UI Tests
    androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.1.1'
}

https://developer.android.com/jetpack/compose/interop/adding

 

앱에 Jetpack Compose 추가  |  Android Developers

앱에 Jetpack Compose 추가 기존 프로젝트에서 Jetpack Compose를 사용하려면 필요한 설정 및 종속 항목으로 프로젝트를 구성해야 합니다. 개발 환경 설정 정확한 Android 스튜디오 버전 설치 Jetpack Compose로

developer.android.com

 

의존성 추가 후 오류가 사라진것을 확인 할 수 있습니다.

1.2 기존 xml레이아웃에 ComposeView 추가하기

기존에 사용하던 레이아웃이 있다면 ComposeView를 추가하여 xml레이아웃과 함께 사용 할 수 있습니다.

1.3 ComposeView에 컴포즈 UI 불러오기

1.4 첫번째 오류: No virtual method setContent....

No virtual method setContent 오류발생 구글링을 해봤는데 build.gradle파일에 대한 내용만 보여 의존성을 잘못 설정한 것 같아 기존에 예제에서 추가하지 않은 의존성들을 설정했습니다. 아마도 compose true를 설정하지 않아 발생한 에러인 것 같습니다.

아래 설정들을 gradle에 추가로 적용했습니다.

    buildFeatures {
        // Enables Jetpack Compose for this module
        compose true
    }
    ...

    // Set both the Java and Kotlin compilers to target Java 8.
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = "1.8"
    }

    composeOptions {
        kotlinCompilerExtensionVersion '1.1.1'
    }

 

1.5 두번째 오류 발생: This version of the Compose Compiler requires Kotlin version 1.4.32 but you appear...

구글링 검색 결과 아래 값을 변경하면 정상적으로 동작하는 것을 확인하였습니다.

ext.kotlinVersion = '1.6.10'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"

1.6 컴포즈 UI 요소 적용 성공

아직 컴포즈에대해서 정확히 모르지만 일단 UI적용은 성공했으니 차차 문서를 분석하면서 적용해나가보도록 하겠습니다.

 

2. Preview 사용해보기

XML을 사용하다 컴포즈를 사용하려니 역시나 적응이 되지않습니다. ConstraintLayout, Coroutine과 같이 배우고나면 기존에 개발했던것보다 훨씬 좋아진다라는 믿음을 갖고 계속 배우고 있습니다.

 

https://www.youtube.com/watch?v=qvDo0SKR8-k 

 

위 영상에 나오지는 않지만 컴포즈에 preview를 설정하면 실제 기기에서 live로 수정하고 바로 볼 수 있는 것을 확인하였습니다. 기존 프로젝트에 이 preview를 설정하려니 이런저런 환경설정 오류가 발생해 이 오류를 해결한 방법에대해서 정리해봤습니다.

2.1 기기에서 동작하는 프리뷰 기능은 SDK 31 이상을 타겟으로 해야합니다.

 

컴포즈 미리보기를 적용하려는 BaseFeed 모듈의 complieSdk가 30으로 설정되어있어 31로 변경해주었습니다.

2.2 org.gradle.api.tasks.TaskExecutionException: Execution failed for task 오류 발생

원인을 알 수 없는 오류가 발생했습니다. 이럴 때 제가 하는 방법은 의존성 라이브러리들의 버전을 최신을 변경하는 것입니다. 다행히 이번에도 일부라이브러리를 업데이트 하니 해결이 되었습니다.

 

위 오류가 발생했던 라이브러리

implementation "androidx.lifecycle:lifecycle-common:$archLifecycleVersion"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$archLifecycleVersion" // ViewModel
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$archLifecycleVersion" // LiveData

최신 버전으로 변경

//archLifecycleVersion = '2.4.0'
archLifecycleVersion = '2.5.0-rc01'

 

2.3 Preview 적용

이제 화면을 기기에서도 실시간으로 수정 할 수 있게되었습니다.

https://youtu.be/Paih_5RkHIU

 

--------------

[안드로이드][Compose][3] 기존 프로젝트에 컴포즈 UI 적용하기 2 - UI 마이그레이션

1. 수정 방향 설정
2. 기존 xml 레이아웃
3. 컴포즈 코드 작성
4. xml과 비교해보고 느낀점
첨부. 컴포즈 이미지 로드를 위한 코일 적용

1. 수정 방향 설정

컴포즈를 한 번에 적용하려니 피드화면에서 변경해야 할 코드도 너무 많고 필요한데 모르는 기능도 많아 1) 기존에 동작하는 기능은 그대로 유지 2) 컴포즈를 배운만큼 적용 할 수 있도록 시도해보려고 합니다.

compose로 변경 할 영역

2. 기존 레이아웃 코드

기존 xml구현 코드

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
        <variable name="feed" type="com.example.torang_core.data.model.Feed" />
        <import type="android.view.View" />
        <variable name="viewModel" type="com.sarang.base_feed.BaseFeedViewModel" />
    </data>

    <!-- parent layout -->
    <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content">
        <!-- 컴포즈 적용할 뷰 -->
        <androidx.compose.ui.platform.ComposeView android:id="@+id/greeting" android:layout_width="wrap_content" android:layout_height="wrap_content" android:visibility="invisible" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" />
        <!-- 기존 피드 상단영역 부모 레이아웃 -->
        <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:paddingStart="16dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent">
            <!-- 우측 점3개 메뉴 -->
            <ImageView android:id="@+id/toolbar" android:layout_width="50dp" android:layout_height="50dp" android:onClick="@{()->viewModel.showMenu(feed)}" android:padding="10dp" android:src="@drawable/ic_dot" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" />
            <!-- 프로필 이미지 -->
            <ImageView android:id="@+id/imageView2" loadCircleImage="@{feed.profile_pic_url}" android:layout_width="30dp" android:layout_height="30dp" android:onClick="@{()->viewModel.openProfile(feed.userId)}" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:srcCompat="@tools:sample/avatars" tools:visibility="visible" />
            <!-- 닉네임 -->
            <TextView android:id="@+id/textView22" android:layout_width="wrap_content" android:layout_height="wrap_content" android:onClick="@{()->viewModel.openProfile(feed.userId)}" android:paddingLeft="8dp" android:paddingRight="8dp" android:text="@{feed.userName}" android:textColor="#000000" app:layout_constraintBottom_toTopOf="@+id/tv_restaurant" app:layout_constraintStart_toEndOf="@+id/imageView2" app:layout_constraintTop_toTopOf="@+id/imageView2" app:layout_constraintVertical_chainStyle="packed" tools:text="name" tools:visibility="visible" />
            <!-- 맛집 상호 -->
            <TextView android:id="@+id/tv_restaurant" android:layout_width="wrap_content" android:layout_height="wrap_content" android:onClick="@{()->viewModel.openTorangDetail(feed.restaurantId)}" android:paddingLeft="8dp" android:paddingRight="8dp" android:text="@{feed.restaurantName}" app:layout_constraintBottom_toBottomOf="@+id/imageView2" app:layout_constraintStart_toStartOf="@+id/textView22" app:layout_constraintTop_toBottomOf="@+id/textView22" tools:text="restaurant" tools:visibility="visible" />
            <!-- 평점바 -->
            <RatingBar android:id="@+id/ratingBar4" style="@style/Widget.AppCompat.RatingBar.Small" android:layout_width="wrap_content" android:layout_height="wrap_content" android:rating="@{feed.rating}" android:visibility="@{feed.rating == null ? View.GONE : View.VISIBLE}" app:layout_constraintBottom_toBottomOf="@+id/textView22" app:layout_constraintStart_toEndOf="@+id/textView22" app:layout_constraintTop_toTopOf="@+id/textView22" />
        </androidx.constraintlayout.widget.ConstraintLayout>
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

 

3. 컴포즈 코드 작성

/**
 * 피드 상단 레이아웃
 */
@OptIn(ExperimentalPagerApi::class)
@Composable
fun FeedTop(profilePicUrl: String, userName: String, restaurantName: String, clickProfile: (() -> Unit)? = null, clickRestaurant: (() -> Unit)? = null, clickMenu: (() -> Unit)? = null) {
    // 가로로 채울 레이아웃
    Row(modifier = Modifier.padding(start = 16.dp).fillMaxWidth().height(50.dp), verticalAlignment = Alignment.CenterVertically) {
        // 프로필 이미지
        ProfileImage(profilePicUrl, clickProfile)
        Spacer(modifier = Modifier.width(8.dp))
        // 세로로 채울 레이아웃
        Column(verticalArrangement = Arrangement.Center) {
            // 닉네임과 평점바 영역
            Row(verticalAlignment = Alignment.CenterVertically) {
                Text(text = userName, fontSize = 14.sp, modifier = Modifier.padding(end = 10.dp).clickable { clickProfile?.invoke() })
                RatingBar()
            }
            // 맛집 상호
            Text(text = restaurantName, fontSize = 14.sp, color = Color.Gray, modifier = Modifier.clickable { clickRestaurant?.invoke() })
        }
        // 우측 메뉴
        Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
            Image(modifier = Modifier.width(50.dp).padding(10.dp).clickable { clickMenu?.invoke() }, painter = painterResource(id = R.drawable.ic_dot), contentDescription = null)
        }
    }
}

 

4. 컴포즈와 xml과 비교해보고 느낀점

xml이 익숙한 저에게는 컴포즈로 작성하는코드는 난이도가 더욱 높았습니다. xml과 비교하여 실시간으로 렌더링되는 부분도 아직 적응하기 어렵네요. 

UI를 개발해왔던 과정을 생각해보면

- 처음에는 xml작성후 -> 액티비티에서 뷰를 불러와 조건에 따라 뷰를 변경

- 그 후 데이터 바인딩을 통해 xml에 조건에 따라 뷰를 변경

- 이제 컴포즈를 통해 뷰를 코틀린으로 작성하고 조건에 따라 뷰를 변경  + xml단위로 UI가 나뉘어졌다면 -> 함수단위로 UI를 작성가능 

 

코틀린 코드, 명령형, 함수단위 UI작성으로 새로운 방식의 UI작성에 적응해야하지만 유연성이 높아지며 재사용성까지 함께 높아지는 것 같았습니다. 아직 시작단계이지만 계속해서 배워야 할 것 같습니다.

 

첨부. 컴포즈 이미지 로드를 위한 코일 적용

아래와 같이 미리보기 화면으로 적용해나가고 있는데 프로필 이미지 로딩 하는부분에서 또 막혀버렸습니다. 컴포즈의 Image 함수는 ImageView클래스와는 다른 것 같아 새로운 라이브러리를 적용해야하는 것 같습니다. 

 

 

안드로이드에서 컴포즈용 샘플 코드를 제공하는데 기존에 간단하게 제공하던 샘플 코드와 달리 포트폴리오와 같은 형태로 좀더 많은 기능들을 담에 제공해주는 것 같습니다.

https://github.com/android/compose-samples

 

GitHub - android/compose-samples: Official Jetpack Compose samples.

Official Jetpack Compose samples. Contribute to android/compose-samples development by creating an account on GitHub.

github.com

Crane 샘플 코드

@Composable
private fun ExploreImage(item: ExploreModel) {
    Box {
        val painter = rememberAsyncImagePainter(
            model = ImageRequest.Builder(LocalContext.current)
                .data(item.imageUrl)
                .crossfade(true)
                .build()
        )

coil 이라는 이미지 로드 라이브러리를 사용하고 있어 저도 이번에 적용해보았습니다.

 

 

의존성 추가하기

implementation("io.coil-kt:coil-compose:2.1.0")

이미지 적용 코드

@Composable
fun MessageCard(userName: String?, restaurantName: String?, profileImageUrl: String?) {
    // Add padding around our message
    Row(
        modifier = Modifier
            .padding(start = 16.dp)
            .fillMaxWidth()
            .height(50.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        profileImageUrl?.let {
            //프로필 이미지 추가
            ExploreImage(it)
        }
...
}

...
@Composable
fun ExploreImage(imageUrl: String) {
    Box {
        val painter = rememberAsyncImagePainter(
            model = ImageRequest.Builder(LocalContext.current)
                .data(imageUrl)
                .crossfade(true)
                .build()
        )

        if (painter.state is AsyncImagePainter.State.Loading) {
            Image(
                painter = painterResource(id = R.drawable.b3s),
                contentDescription = null,
                modifier = Modifier
                    .size(36.dp)
                    .align(Alignment.Center),
            )
        }

        Image(
            painter = painter,
            contentDescription = null,
            contentScale = ContentScale.Crop,
            modifier = Modifier
                // Set image size to 40 dp
                .size(30.dp)
                // Clip image to be shaped as a circle
                .clip(CircleShape),
        )
    }
}

--------

[안드로이드][Compose][4] 컴포즈 ViewPager 만들기

컴포즈로 기존의 프로젝트를 마이그래이션 하던중에 ViewPager를 적용할 차례가 되었습니다.

구글에 검색해보니 아직 많은 정보가 없는 것 같았습니다. 

 

안드로이드 github compose-sample에 Jetcaster라는 샘플 프로젝트가 있는데 다행히 이 프로젝트에 컴포즈로 구현된 ViewPager가 있어 적용해보았습니다.

 

https://github.com/android/compose-samples

 

GitHub - android/compose-samples: Official Jetpack Compose samples.

Official Jetpack Compose samples. Contribute to android/compose-samples development by creating an account on GitHub.

github.com

 

1. 의존성 추가하기

ViewPager를 위해 accompanist-pager라는 라이브러리를 만든 것 같습니다.

implementation 'com.google.accompanist:accompanist-pager:0.24.9-beta'

 

2. 구현하기

기존에 구현했던 Pager보다 Adapter 클래스 적용하는 절차없이 구현이 가능 한 것 같아 매우 쉽게 적용이 가능했습니다.

@OptIn(ExperimentalPagerApi::class)
@Composable
fun Greeting(name: String) {
    HorizontalPager(
        count = 10,
        state = rememberPagerState(),
    ) { pager ->

        Text(text = "Hello $name!")

    }
}

 

https://youtu.be/Me5jlO6xfH8

 

https://github.com/sarang628/ComposeViewPager

 

GitHub - sarang628/ComposeViewPager

Contribute to sarang628/ComposeViewPager development by creating an account on GitHub.

github.com

 

+ Recent posts