[Kotlin/Compose] Slide-Unlock(밀어서 잠금해제) 기능 개발하기 (1)

Slide-Unlock 기능이 무엇인가?

요즘 잠금 해제 기능은 다들 지문 인식 또는 페이스 아이디를 통해 잠금 해제를 하는데, 몇 년 전에는 밀어서 잠금 해제를 많이 쓰곤 했다.

밀어서 잠금해제~

대충 이런 느낌인데, 최근에 AnchoredDraggable에 대해 학습한 김에, 다음 기능을 구현해보고자 한다.

오늘의 완성본

스윽 스윽

구현

enum class

가장 먼저 상태를 표현할 enum class를 생각해보자.
잠금 해제의 상태는 처음 - 끝 으로 표현이 가능하다.

enum class Position{  
    Start,End  
}

state 구현

다음은 가장 중요하다고 생각하는 컴포저블의 드래그 상태를 저장하는 state의 구현이다.

val state = remember {  
    AnchoredDraggableState(  
        initialValue = Position.Start,  
        positionalThreshold = {totalDistance : Float -> totalDistance * 0.5f },  
        velocityThreshold = { Float.MAX_VALUE },  
        animationSpec = tween(),  
    )  
}

 

velocity의 경우에는 값을 Float.MAX_VALUE로 주어 사용자가 살짝 드래그 했을 때 슉 이동하지 않도록 하였다.

중요한 부분은 Anchorsupdate부분이다.


AnchorEnd는 컴포저블의 전체 width에서 padding * 2 + width(미는 대상의 넓이)를 빼주어야 한다.

컴포저블의 넓이는 부모에 따라 결정이 된다.


이를 측정하기 위해서는 modifieronGloballyPositioned로 측정이 가능하다.

 

여기서 주의해야 할 점은 Box의 크기가 결정이 되어야 해당 기준으로 AnchorEnd를 결정해야 한다는 부분이다.

이를 위해, LauncedEffect를 사용하여, 값이 변경될 때 호출하여 Anchor를 업데이트 해야 합니다.

var componentSize by remember {  
    mutableStateOf(IntSize.Zero)  
}

val density = LocalDensity.current

LaunchedEffect(componentSize) {  
    if(componentSize.width > 0) {  
        val endPosition = with(density){(componentSize.width - 40.dp.toPx() - 16.dp.toPx())}  
        state.updateAnchors(  
            DraggableAnchors {  
                Position.Start at -0f  
                Position.End at endPosition  
            }  
        )  
    }  
}

 

Start의 경우에는 시작점이므로 0f, End의 경우에는 끝이므로 위에서 설명한 수식으로 계산해주었습니다.

레이아웃 만들기

Box(  
    modifier = Modifier  
        .fillMaxSize()  
        .padding(8.dp)  
){  
    Box(  
        modifier = Modifier  
            .fillMaxWidth()  
            .height(60.dp)  
            .background(color = Color.Yellow, shape = RoundedCornerShape(40.dp))  
            .align(Alignment.Center)  
            .onGloballyPositioned {  
                componentSize = it.size  
            }  
    ){  

    }  
    val safeOffset = if(state.offset.isNaN()) 0f else state.offset
    Box(  
        modifier = Modifier  
            .align(Alignment.CenterStart)  
            .offset {  
                IntOffset((safeOffset.roundToInt()), 0)  
            }  
            .padding(horizontal = 8.dp)  
    ){  
        Box(  
            modifier = Modifier  
                .size(40.dp)  
                .clip(CircleShape)  
                .background(color = Color.Blue)  
                .anchoredDraggable(  
                    state = state,  
                    orientation = Orientation.Horizontal  
                )  
        ){  
            Text(text = "B", color = Color.White, modifier = Modifier.align(Alignment.Center))  
        }  
    }}

가장 먼저 slide를 둘러싸고 있는 부모 역할을 해주는 Box를 생성합니다.

 

그리고 Box의 경우에는 나중에 선언된 코드가 상단에 위치하기 때문에, 가장 먼저 배경을 담당하는 코드를 작성해줍니다.

배경의 경우에는 임시로 노란색과 border를 설정하였습니다.
이때, componentSize를 업데이트 해주어야 합니다. onGloballyPositioned를 이용하여, 해당 컴포넌트의 넓이를 저장해줍니다.

 

이렇게 되면, 컴포넌트가 생성이 되고 componentSize에 값이 할당되면, 위의 LaunchEffect코드가 실행이 됩니다.

다음은 잡아당길 원형의 코드 입니다.


원의 경우에는 잡아 당길 때마다, 이동거리만큼 offset이 업데이트 되어져야 하므로, state에 따라 값이 업데이트 되도록 설정하였습니다.

덜 갔으면 돌아오기

물론, 여기까지 구현만 하면 살짝 밀면 끝까지 날라가게 됩니다.
반 이상 밀었을 때 끝까지 이동하도록 코드를 추가하면 다음과 같습니다.

LaunchedEffect(state.currentValue) {  
    if(state.currentValue == Position.Start && state.offset > componentSize.width.toFloat() / 2) {  
        state.snapTo(Position.End)  
    }  
}

전체 코드

class MainActivity : ComponentActivity() {  
    override fun onCreate(savedInstanceState: Bundle?) {  
        super.onCreate(savedInstanceState)  
        setContent {  
            SlideandunlockTheme {  
                // A surface container using the 'background' color from the theme  
                Surface(  
                    modifier = Modifier.wrapContentSize(),  
                    color = MaterialTheme.colorScheme.background  
                ) {  
                    Track()  
                }  
            }        }    }  
}  

enum class Position{  
    Start,End  
}  

@OptIn(ExperimentalFoundationApi::class)  
@Preview  
@Composable  
private fun Track(modifier: Modifier = Modifier)  
{  
    var componentSize by remember {  
        mutableStateOf(IntSize.Zero)  
    }  
    val density = LocalDensity.current  

    val state = remember {  
        AnchoredDraggableState(  
            initialValue = Position.Start,  
            positionalThreshold = {totalDistance : Float -> totalDistance * 0.5f },  
            velocityThreshold = { Float.MAX_VALUE },  
            animationSpec = tween(),  
        )  
    }  
    LaunchedEffect(componentSize) {  
        if(componentSize.width > 0) {  
            val endPosition = with(density){(componentSize.width - 40.dp.toPx() - 16.dp.toPx())}  
            state.updateAnchors(  
                DraggableAnchors {  
                    Position.Start at -0f  
                    Position.End at endPosition  
                }  
            )  
        }  
    }  
    LaunchedEffect(state.currentValue) {  
        if(state.currentValue == Position.Start && state.offset > componentSize.width.toFloat() / 2) {  
            state.snapTo(Position.End)  
        }  
    }  

    Box(  
        modifier = Modifier  
            .fillMaxSize()  
            .padding(8.dp)  
    ){  
        Box(  
            modifier = Modifier  
                .fillMaxWidth()  
                .height(60.dp)  
                .background(color = Color.Yellow, shape = RoundedCornerShape(40.dp))  
                .align(Alignment.Center)  
                .onGloballyPositioned {  
                    componentSize = it.size  
                }  
        ){  

        }  
        val safeOffset = if(state.offset.isNaN()) 0f else state.offset  

        Box(  
            modifier = Modifier  
                .align(Alignment.CenterStart)  
                .offset {  
                    IntOffset((safeOffset.roundToInt()), 0)  
                }  
                .padding(horizontal = 8.dp)  
        ){  
            Box(  
                modifier = Modifier  
                    .size(40.dp)  
                    .clip(CircleShape)  
                    .background(color = Color.Blue)  
                    .anchoredDraggable(  
                        state = state,  
                        orientation = Orientation.Horizontal  
                    )  
            ){  
                Text(text = "B", color = Color.White, modifier = Modifier.align(Alignment.Center))  
            }  
        }    }}