Android

  • SwipeToDismiss(밀어서 삭제) 방식은 리스트에서 아이템을 삭제하거나, 좋아요나 플레이리스트 등 특정 그룹에 추가하는 기능을 구현할 때 용이하다.

    Jetpack Compose에서는 이를 쉽게 제작할 수 있도록 SwipeToDismiss 라는 Composable 함수를 지원한다.

    작성 방법이 쉽기 때문에 금방 따라 작성할 수 있다.

    val dismissState = rememberDismissState(confirmStateChange = {               
        if (it == DismissValue.DismissedToStart) {                              
            /**                                                                 
             * Dismiss 되었을 때 작업할 코드 작성                                          
             */                                                                 
        }                                                                       
        true                                                                     
    })                                                                          
                                                                                
    SwipeToDismiss(state = dismissState, directions = setOf(                    
        DismissDirection.EndToStart                                             
    ), dismissThresholds = { direction ->                                       
        FractionalThreshold(0.4f)                                               
    }, background = {                                                            
        val color by animateColorAsState(                                       
            when (dismissState.targetValue) {                                   
                DismissValue.Default -> Color.White                             
                else -> Color.Red                                               
            }                                                                   
        )                                                                       
        val scale by animateFloatAsState(                                       
            if (dismissState.targetValue == DismissValue.Default) 0.75f else 1f 
        )                                                                       
        Box(                                                                    
            modifier = Modifier                                                 
                .fillMaxSize()                                                  
                .background(color)                                              
                .padding(horizontal = Dp(20f)),                                 
            contentAlignment = Alignment.CenterEnd                              
        ) {                                                                      
            Icon(                                                               
                Icons.Default.Delete,                                           
                contentDescription = "Delete Icon",                             
                modifier = Modifier.scale(scale)                                
            )                                                                   
        }                                                                       
    }, dismissContent = {                                                        
        Card(                                                                   
            shape = RoundedCornerShape(0.dp),                                   
            elevation = animateDpAsState(                                       
                if (dismissState.dismissDirection != null) 4.dp else 0.dp       
            ).value                                                             
        ) {                                                                     
            /**                                                                 
             * 원본 콘텐츠 구성가능한함수 작성                                                
             */                                                                 
        }                                                                       
    })                                                                          

    state : 앞선 코드에서 선언한 DismissState 에서 confirmStateChange 콜백 함수를 통해 SwipeToDismiss Composable 함수의 Dismiss 상태를 추적할 수 있다.

    DismissValue.DismissedToStart : 오른쪽에서 왼쪽으로 Dismiss 됨

    DismissValue.DismissedToEnd : 왼쪽에서 오른쪽으로 Dismiss 됨

    Default : Dismiss 되지 않은 상태

    의도한 방향으로 Dismiss 된 경우 리스트 아이템 삭제 등 작업을 수행하면 된다.

    dismissThresholds : Dismiss 되는 임계치 이다. 0.0f ~ 1.0f, ex) 0.5f 로 설정한 경우 50% 이상 Swipe 된 경우 Dismiss 된 것으로 간주

    background : Dismiss 된 경우 후방에 위치하는(본 컨텐츠 뒤에 위치하는) Composable 함수. 이전에 선언한 DismissState에 따라 애니메이션을 삽입 할 수 있다.

    dismissContent : 본 컨텐츠가 되는 Composable 함수 (Dismiss 되기 전까지 보여지는 컨텐츠)

    아이템 삭제시 자연스럽게 하기

    DismissedToStart등 State 반응시 즉시 아이템을 제거하는 등의 작업을 하게 되면 UI 가 부자연스럽다.

    이를 해결하기 위해, SwipeToDismiss의 modifier에 animateItemPlacement()를 적용하면 리스트에서 아이템이 제거 되었을 때 보다 자연스럽게 반응한다.

    또한 다음과 같이 일정 시간 동안 딜레이를 준 뒤 본 작업을 수행 하는 것이 더 보기 좋다.

    val dismissState = rememberDismissState(confirmStateChange = {               
        if (it == DismissValue.DismissedToStart) {                              
            CoroutineScope(Dispatchers.Main).launch {                            
                delay(400)                                                      
                viewModel.remove(resource)                                      
            }                                                                   
        }                                                                       
        true                                                                     
    })                                                                          
  • Compose로 구성되는 Fragment의 BaseFragment 부모 클래스를 다음과 같이 작성할 수 있다.

    abstract class ComposeBaseFragment<R : BaseViewModel> : Fragment() {
        abstract val viewModel: R
    
        @Composable
        abstract fun Compose()
    
        abstract fun afterCompose()
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
        }
    
        override fun onCreateView(
            inflater: LayoutInflater,
            container: ViewGroup?,
            savedInstanceState: Bundle?
        ): View? {
            val compose = ComposeView(requireContext()).apply {
                setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
                setContent {
                    MaterialTheme {
                        Compose()
                    }
                }
            }
            afterCompose()
            return compose
        }
    }

    이 ComposeBaseFragment 를 상속해서 만든 Fragment 에서는

    “Compose” Composable 함수 안에 Compose 코드들을 구성하고, afterCompose() 에서는 컴포즈 구성 이후 실행되어야 하는 다른 코드들을 작성할 수 있다.

    필요에 따라 beforeCompose() 와 같은 함수를 만들어서 컴포즈 구성 이전에 실행되어야 하는 코드를 작성할 수도 있다.

  • <androidx.coordinatorlayout.widget.CoordinatorLayout
            android:id="@+id/coordinator"
            android:layout_width="match_parent"
            android:layout_height="match_parent">
    
            <com.google.android.material.appbar.AppBarLayout
                android:id="@+id/appBarLayout"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:background="@android:color/white"
                app:elevation="0dp" />
    
    
            <androidx.compose.ui.platform.ComposeView
                android:id="@+id/compose_view"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                app:layout_behavior="@string/appbar_scrolling_view_behavior" />
    
    ...
    
    </androidx.coordinatorlayout.widget.CoordinatorLayout>

    다음과 같이 CoordinatorLayout의 자식으로 ComposeView를 가지게 되는 구성에서, ComposeView의 스크롤 이벤트를 의도대로 처리할 수 없다.

    CoordinatorLayout 과 해당 레이아웃 내의 ComposeView 간 스크롤 상호작용이 안되는 문제는 다음과 같이 해결 할 수 있다.

    val nestedScrollInterop = rememberNestedScrollInteropConnection()
            
    Surface(modifier = Modifier.nestedScroll(nestedScrollInterop)) {
        ...
    }

    다음과 같이 제공되는 NestedScrollConnection를 생성하고, ComposeView 의 최상단 Composable에 nestedScroll 수정자를 통해 적용하면 문제가 해결된다.

    중첩 스크롤 상호 운용성 > 하위 ComposeView를 포함하는 협력 상위 뷰