Gyeongrok Kim

  • 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를 포함하는 협력 상위 뷰

  • Character to Integer

    ord('A') # 65
    ord('Z') # 90
    ord('a') # 97
    ord('z') # 122

    Integer to Character

    chr(65) # A
    chr(90) # Z
    chr(97) # a
    chr(122) # z
    • 알파벳은 26자이다.
  • Firebase Firestore | 문서 시각 검증

    ·

    서비스에서 악성 사용자를 일정 시간 동안 차단하거나, 회원권이나 쿠폰의 유효 기간을 검증, 시각에 따라 특정 기능을 제한하는 등 많은 경우에 시간 유효성을 검증해야 할 필요가 있다.

    이 때, 클라이언트에서 제공하는 시간 정보를 사용하게 되면 이용자가 쉽게 조작할 수 있게 된다.

    JavaScript의 Date 클래스, Java의 currentTimeMillis 함수 처럼 시스템의 시간을 가져오는 함수를 위 작업을 수행하는 이용하면 기기의 시간이 변경되는 것 만으로도 큰 문제를 야기할 수 있다.

    현재 서버 시각 삽입하기

    Firebase 의 Firestore 나 Realtime Database 에서는 FieldValue (Firestore) 혹은 ServerValue (Realtime Database)를 이용하여 현재 서버시각을 삽입 할 수 있다.

    // Firestore
    FieldValue.serverTimestamp()
    // Realtime Database
    ServerValue.TIMESTAMP

    데이터 삽입시 위 값을 삽입하면 데이터베이스에 현재 서버시각이 (Firestore) Timestamp 자료형으로 기록된다.

    문서의 Timestamp 요소를 현재 서버 시각과 대조하기

    반대로 문서를 쿼리 할 때에는 FieldValue 나 ServerValue 같은 변수를 사용할 수 없다. Firestore의 보안 규칙을 활용해서 현재 서버 시각과 문서의 시각을 대조할 수 있다.

    보안규칙은 특정조건(이용자, 문서 값)에 따라 문서의 읽기, 쓰기, 삭제 권한의 허용 여부를 결정할 수 있게한다.

    match /suspensions_user/{userId} {
        	allow read : if resource.data.releasedTimestamp > request.time
    }
    1. suspensions_user 콜렉션에는 서비스 이용이 제한된 이용자의 데이터를 보관한다.
    2. 위와 같이 보안규칙을 작성해서 요청하는 문서의 “releasedTimestamp” (이용 제한 해제 시각) 값이 요청 시각 보다 클 때에만 읽기(read)를 허용했다.
    3. 매치 되는 경로의 문서에 접근할 수 있게 되어 성공적으로 문서를 읽어오게 되면 경로에 포함 된 userId를 가진 이용자는 이용 정지 상태임을 알 수 있다.

    이를 이용해 특정 시각을 넘겨 수행하는 작업들을 차단하는 코드를 작성할 수 있다.