Android

카카오톡 채팅화면 키보드 옵션창 구현

그란. 2022. 10. 9. 17:28

카카오톡 채팅화면의 키보드와 옵션창 구현 내용 정리 

 

 

개발 과정 

1. 레이아웃 구성 

2. 로직 적용 

3. 부드러운 모션 적용

 

1. 레이아웃 구성

채팅 리사이클러뷰 하단에 height="0dp"로 옵션창 추가 

<androidx.constraintlayout.widget.ConstraintLayout
    android:id="@+id/extra"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:background="@color/systemGrey06"
    android:paddingHorizontal="4dp"
    android:paddingVertical="36dp"
    android:visibility="gone"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    tools:layout_height="0dp">

    <include
        android:id="@+id/ib_album"
        layout="@layout/item_extra"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:image="@{@drawable/ic_extra_album}"
        app:layout_constraintEnd_toStartOf="@id/ib_camera"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:text="@{`앨범`}" />

    <include
        android:id="@+id/ib_camera"
        layout="@layout/item_extra"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:image="@{@drawable/ic_extra_camera}"
        app:layout_constraintEnd_toStartOf="@id/ib_video"
        app:layout_constraintStart_toEndOf="@id/ib_album"
        app:layout_constraintTop_toTopOf="parent"
        app:text="@{`카메라`}" />

    <include
        android:id="@+id/ib_video"
        layout="@layout/item_extra"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:image="@{@drawable/ic_extra_video}"
        app:layout_constraintEnd_toStartOf="@id/ib_capture"
        app:layout_constraintStart_toEndOf="@id/ib_camera"
        app:layout_constraintTop_toTopOf="parent"
        app:text="@{`동영상`}" />

    <include
        android:id="@+id/ib_capture"
        layout="@layout/item_extra"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:visibility="invisible"
        app:image="@{@drawable/ic_extra_capture}"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@id/ib_video"
        app:layout_constraintTop_toTopOf="parent"
        app:text="@{`캡처`}" />

</androidx.constraintlayout.widget.ConstraintLayout>

( 이게 하단 옵션 창 ) 

 

 


2. 로직 적용 

기본 세팅 

WindowInsets을 사용하기 위해 시스템 Inset을 제거 한다. ( 내가 컨트롤 할 예정 ) 

override fun onCreate(savedInstanceState: Bundle?) {
    WindowCompat.setDecorFitsSystemWindows(window, false)
    super.onCreate(savedInstanceState)
}

그리고 키보드가 올라왔을때와 시스템바에 대한 패딩을 준다 ( 전체 레이아웃 root에 ) 

ViewCompat.setOnApplyWindowInsetsListener(root){ _,insets ->
     val mergedInset = insets.getInsets(
                    WindowInsetsCompat.Type.ime() or
                            WindowInsetsCompat.Type.systemBars()
     	           )

	root.updatePadding(
                top = mergedInset.top,
                left = mergedInset.left,
                bottom = mergedInset.bottom,
                right = mergedInset.right
            )

	insets
}

 

 

그 다음엔 옵션창의 높이를 키보드의 높이와 맞춰야 하는데 키보드의 높이를 알 방법이 없다. 

알기 위해선 키보드를 show하고 그 값을 저장 해야 한다.

 

전역 변수로 keyboardTop을 만들고 추가적으로 현재 상태값도 추가한다.

private var keyboardTop = 0
private var currentImeState: ImeState = ImeState.HIDE

enum class ImeState {
    HIDE, KEYBOARD, OPTION
}

키보드가 보일때 keyboardTop에 키보드의 높이를 저장해놓는다.

중요한 점 : 키보드가 올라올때는 키보드만 최하단에서 올라온다. ( 시스템바에 영향 X) 

그래서 rootPadding 처리는 mergedInset.bottom 을 사용하고 keyboard 만의 높이를 가져오기 위해 시스템바 bottom을 뺐다.

ViewCompat.setOnApplyWindowInsetsListener(root){ _,insets ->
     val mergedInset = insets.getInsets(
                    WindowInsetsCompat.Type.ime() or
                            WindowInsetsCompat.Type.systemBars()
     	           )
                   
                   
    systembarBottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom                   
   
       		if (isVisibleKeyboard) {
                    if (keyboardTop == 0) {
                        keyboardTop = mergedInset.bottom - systembarBottom
                        viewDataBinding.extra.updateLayoutParams<ViewGroup.LayoutParams> {
                            height = keyboardTop
                        }
                    }
                    viewDataBinding.extra.isGone = true
                    viewDataBinding.ibPlus.setImageDrawable(plusImage)
                    currentImeState = ImeState.KEYBOARD
                } else if (!isVisibleKeyboard && extra.isGone) {
                    currentImeState = ImeState.HIDE
                }

                   
	root.updatePadding(
                top = mergedInset.top,
                left = mergedInset.left,
                bottom = mergedInset.bottom,
                right = mergedInset.right
            )

	insets
}

 

isVisibleKeyboard 는 키보드가 열려져있는지 확인하는 프로퍼티다

private val isVisibleKeyboard: Boolean
    get() = ViewCompat.getRootWindowInsets(viewDataBinding.root)
        ?.isVisible(WindowInsetsCompat.Type.ime()) == true

 

그리고 상태에 따라 UI를 처리하는 함수를 만든다.

private fun statusAction(status: ImeState) {
    when (status) {
        ImeState.HIDE -> {
            hideKeyboard()
            viewDataBinding.extra.isGone = true
            viewDataBinding.ibPlus.setImageDrawable(plusImage)
            currentImeState = ImeState.HIDE
        }
        ImeState.KEYBOARD -> {

            if (!isVisibleKeyboard) {
                showKeyboard()
            }
            viewDataBinding.ibPlus.setImageDrawable(plusImage)
            viewDataBinding.extra.isGone = true
            currentImeState = ImeState.KEYBOARD
        }
        ImeState.OPTION -> {
                lifecycleScope.launch {
                   
                    if (keyboardTop == 0) {
                        showKeyboard()
                    }
                    if (isVisibleKeyboard) {
                        hideKeyboard()
                    }
                    viewDataBinding.ibPlus.setImageDrawable(closeImage)
                    viewDataBinding.extra.isVisible = true
                    currentImeState = ImeState.OPTION
                }
            }
    }
}

일단 중요한 로직은 OPTION 부분인데

케이스 분리를 하면 

1. 처음 + 를 눌러 옵션창이 켜지는 경우

2. 처음 이후 +를 눌러 옵션창이 켜지는 경우

3. 키보드가 열려있는 상태에서 +를 눌러 옵션창이 보이는 경우

 

그리고 이벤트 연결 

ibPlus.setOnClickListener {
    statusAction(
        when (currentImeState) {
            ImeState.HIDE, ImeState.KEYBOARD -> ImeState.OPTION
            ImeState.OPTION -> ImeState.KEYBOARD
        }
    )
}

 


3. 부드러운 모션 적용

그런데 이렇게 하면 문제가 발생한다. 

키보드를 show, hide 하는 과정은 비동기기 때문에 다른 UI와 동시에 변경할때 이상현상이 생긴다. 

 

그래서 키보드동작들을 동기로 만든다. 

동기로 만든다는 의미는 showKeyboard -> keyboard show Success -> 다음 동작을 의미한다. ( 콜백 받고 처리한다는 얘기 ) 

 

suspend fun View.awaitLayoutChanged() = suspendCancellableCoroutine { cont ->
    val listener = object : View.OnLayoutChangeListener {
        override fun onLayoutChange(
            v: View?,
            left: Int,
            top: Int,
            right: Int,
            bottom: Int,
            oldLeft: Int,
            oldTop: Int,
            oldRight: Int,
            oldBottom: Int
        ) {
            v?.removeOnLayoutChangeListener(this)
            cont.resumeWith(Result.success(Unit))
        }
    }
    cont.invokeOnCancellation { removeOnLayoutChangeListener(listener) }
    addOnLayoutChangeListener(listener)
}

 

 

 

뷰의 콜백을 받는 원샷 suspend 함수를 만들고 

 

private fun statusAction(status: ImeState) {
    when (status) {
        ImeState.HIDE -> {
            hideKeyboard()
            viewDataBinding.extra.isGone = true
            viewDataBinding.ibPlus.setImageDrawable(plusImage)
            currentImeState = ImeState.HIDE
        }
        ImeState.KEYBOARD -> {
            lifecycleScope.launch {
                if (!isVisibleKeyboard) {
                    showKeyboard()
                    viewDataBinding.root.awaitLayoutChanged()
                }
                viewDataBinding.ibPlus.setImageDrawable(plusImage)
                viewDataBinding.extra.isGone = true
                currentImeState = ImeState.KEYBOARD
            }
        }
        ImeState.OPTION -> {
            lifecycleScope.launch {
                if (keyboardTop == 0) {
	                viewDataBinding.etMessage.requestFocus()   //이건 하위버전 대응 
                    showKeyboard()
                    viewDataBinding.root.awaitLayoutChanged()
                }
                if (isVisibleKeyboard) {
                    hideKeyboard()
                    viewDataBinding.root.awaitLayoutChanged()
                }
                viewDataBinding.ibPlus.setImageDrawable(closeImage)
                viewDataBinding.extra.isVisible = true
                currentImeState = ImeState.OPTION
            }
        }
    }
}

이와 같은 형태로 변경 ( 키보드 상태 변경할때까지 기다리고 다음 동작 한다 )