관리 메뉴

나만을 위한 블로그

[Android] BottomSheetDialogFragment 스크롤 시 내부 요소가 스크롤되게 하는 법 본문

Android

[Android] BottomSheetDialogFragment 스크롤 시 내부 요소가 스크롤되게 하는 법

참깨빵위에참깨빵_ 2023. 11. 16. 22:27
728x90
반응형

BottomSheetDialogFragment(이하 바텀 시트)를 구현한 다음, 이 안에 웹뷰 등 스크롤 가능한 뷰를 넣고 아래로 스크롤하면 잘 된다.

그러나 위로 스크롤하면 스크롤됐던 뷰가 도로 올라가는 게 아니라, 바텀 시트가 밑으로 점점 내려간다.

이렇게 되면 아래 방향으로 스크롤은 가능하지만, 위로 다시 스크롤할 수 없는 바텀 시트가 만들어진다. 이걸 막으려면 어떻게 해야 할까?

바로 BottomSheetBehavior라는 클래스를 통해 isDraggable 프로퍼티에 true, false를 대입하면 된다. 무슨 소린가 싶을테니 코드부터 본다. 먼저 바텀 시트의 예시 코드다.

 

<?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="title"
            type="String" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <TextView
            android:id="@+id/tvTitle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="40dp"
            android:textSize="30dp"
            android:text="@{title}"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:text="Hello, There!" />

        <WebView
            android:id="@+id/wbBottomSheet"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintTop_toBottomOf="@+id/tvTitle"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"/>

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.webkit.WebChromeClient
import android.webkit.WebViewClient
import android.widget.FrameLayout
import com.example.kotlinprac.R
import com.example.kotlinprac.databinding.MyBottomSheetDialogBinding
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment

class MyBottomSheet(
    private var bottomSheetTitle: String
) : BottomSheetDialogFragment() {

    private lateinit var binding: MyBottomSheetDialogBinding
    private lateinit var bottomSheetBehavior: BottomSheetBehavior<View>

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = MyBottomSheetDialogBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onStart() {
        super.onStart()

        val bottomSheet = dialog?.findViewById<View>(R.id.design_bottom_sheet) as FrameLayout
        bottomSheet.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
        bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        binding.title = bottomSheetTitle
        initWebView()
    }

    @SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
    private fun initWebView() = binding.wbBottomSheet.apply {
        settings.run {
            javaScriptEnabled = true
            domStorageEnabled = true
            allowContentAccess = true
        }
        webViewClient = WebViewClient()
        webChromeClient = WebChromeClient()
        loadUrl("https://m.naver.com")

        setOnTouchListener { _, event ->
            when (event.action) {
                MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
                    // 스크롤 중 드래그 방지
                    bottomSheetBehavior.isDraggable = false
                }

                MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                    // 스크롤이 끝나면 드래그
                    bottomSheetBehavior.isDraggable = true
                }
            }
            false
        }
    }

}

 

이후 액티비티, 프래그먼트에서 MyBottomSheet의 객체를 만들고, show()로 표시하면 된다.

아래는 위에서 만든 바텀 시트의 작동 영상이다.

 

(영상 크기 에러로 삭제)

 

"제목" 글자와 바텀 시트 top 사이의 공간을 클릭해서 위아래로 당기면 그에 따라 바텀 시트가 움직이고, 웹뷰를 스크롤하면 바텀 시트는 그대로 있는 상태에서 웹뷰만 위아래로 스크롤된다. 이제 왜 이렇게 작동하는지 확인한다.

 

먼저 MyBottomSheet의 전역변수로 아래 변수를 선언했다.

 

private lateinit var bottomSheetBehavior: BottomSheetBehavior<View>

 

여기서 제네릭 타입 <View>는 BottomSheetBehavior가 적용될 뷰의 타입이다. View는 모든 UI 컴포넌트의 조상 클래스기 때문에, ConstraintLayout도 View에 포함된다. LinearLayout, FrameLayout 등 어떤 레이아웃을 썼더라도 똑같다. 이 뷰(레이아웃)의 동작을 제어하기 위해 제네릭 타입 <View>를 썼다.

 

override fun onStart() {
    super.onStart()

    val bottomSheet = dialog?.findViewById<View>(R.id.design_bottom_sheet) as FrameLayout
    bottomSheet.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
    bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet)
}

 

첫 2줄은 바텀 시트를 전체 화면으로 표시하기 위해 필요하다. 현재 다이얼로그의 뷰 계층에서 design_bottom_sheet란 id를 가진 뷰를 찾는데, 이 뷰는 바텀 시트의 컨테이너 역할을 하는 FrameLayout이다. 실제로 컨트롤을 누르고 design_bottom_sheet를 클릭하면 design_bottom_sheet_dialog.xml 파일이 열린다.

 

<?xml version="1.0" encoding="utf-8"?>
<!--
  ~ Copyright (C) 2015 The Android Open Source Project
  ~
  ~ Licensed under the Apache License, Version 2.0 (the "License");
  ~ you may not use this file except in compliance with the License.
  ~ You may obtain a copy of the License at
  ~
  ~      http://www.apache.org/licenses/LICENSE-2.0
  ~
  ~ Unless required by applicable law or agreed to in writing, software
  ~ distributed under the License is distributed on an "AS IS" BASIS,
  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
-->
<FrameLayout
    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"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true">

  <androidx.coordinatorlayout.widget.CoordinatorLayout
      android:id="@+id/coordinator"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:fitsSystemWindows="true">

    <View
        android:id="@+id/touch_outside"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:focusable="false"
        android:importantForAccessibility="no"
        android:soundEffectsEnabled="false"
        tools:ignore="UnusedAttribute"/>

    <FrameLayout
        android:id="@+id/design_bottom_sheet"
        style="?attr/bottomSheetStyle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal|top"
        app:layout_behavior="@string/bottom_sheet_behavior"/>

  </androidx.coordinatorlayout.widget.CoordinatorLayout>

</FrameLayout>

 

그리고 이렇게 찾은 뷰를 FrameLayout으로 캐스팅한다. 왜냐면 BottomSheetDialogFragment 안에서 컨테이너 역할을 하는 뷰가 FrameLayout이기 때문이고, getDialog()의 결과가 nullable하기 때문에 이후 로직에서 bottomSheet 변수를 사용하려면 FrameLayout으로의 캐스팅이 필요하다.

그리고 전역으로 선언한 BottomSheetBehavior<View> 타입 객체를 초기화한다. 이 객체를 통해 BottomSheetDialogFragment가 확장, 축소될 때 어떻게 동작할지를 정의할 수 있게 된다.

 

이후 initWebView()에서 모바일 네이버 웹뷰를 초기화한다.

 

@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
private fun initWebView() = binding.wbBottomSheet.apply {
    settings.run {
        javaScriptEnabled = true
        domStorageEnabled = true
        allowContentAccess = true
    }
    webViewClient = WebViewClient()
    webChromeClient = WebChromeClient()
    loadUrl("https://m.naver.com")

    setOnTouchListener { _, event ->
        when (event.action) {
            MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
                // 스크롤 중 드래그 방지
                bottomSheetBehavior.isDraggable = false
            }

            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                // 스크롤이 끝나면 드래그
                bottomSheetBehavior.isDraggable = true
            }
        }
        false
    }
}

 

웹뷰 설정은 넘기고, setOnTouchListener만 보면 된다. 웹뷰 객체에 호출한 apply 안에서 setOnTouchListener를 호출하기 때문에, 이 리스너는 당연히 웹뷰에 적용된다.

setOnTouchListener는 View, MotionEvent의 2가지 매개변수를 사용하는데 View는 지금은 쓰지 않으니 언더바로 대체하고 MotionEvent만 사용한다. MotionEvent만 쓰는 이유는 유저가 지금 스크롤을 하는지 아닌지를 판단하기 위해서다.

그리고 isDraggable이란 프로퍼티가 눈에 띄는데, 이것을 컨트롤을 누르고 클릭하면 BottomSheetBehavior.java 파일로 이동한다.

 

  /**
   * Sets whether this bottom sheet is can be collapsed/expanded by dragging. Note: When disabling
   * dragging, an app will require to implement a custom way to expand/collapse the bottom sheet
   *
   * @param draggable {@code false} to prevent dragging the sheet to collapse and expand
   * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_draggable
   */
  public void setDraggable(boolean draggable) {
    this.draggable = draggable;
  }
이 바텀 시트를 드래그해서 축소 / 확장할 수 있는지 여부를 설정한다
참고 : 드래그를 비활성화하면 앱은 바텀 시트를 축소 / 확장하는 사용자 지정 방법을 구현해야 한다

 

때문에 아래와 같은 흐름에 의거해서 T/F 값을 설정하면 된다.

 

  • 웹뷰를 스크롤하는 경우 : 바텀 시트는 드래그되면 안 된다. 웹뷰가 드래그되는 경우 ACTION_DOWN, ACTION_MOVE 2가지의 MotionEvent를 호출하기 때문에, 이 MotionEvent가 호출될 때는 isDraggable 값을 false로 설정해 바텀 시트가 드래그되지 않게 한다
  • 웹뷰 스크롤이 끝난 경우 : 바텀 시트가 드래그되어야 한다. 이 경우 ACTION_UP, ACTION_CANCEL 2가지의 MotionEvent를 호출하기 때문에, 이 MotionEvent가 호출될 때는 isDraggable 값을 true로 설정해 바텀 시트를 드래그할 수 있게 한다

 

이렇게 구현하면 손가락을 위로 올려서 상단의 빈 부분을 드래그해야 바텀 시트가 확장, 축소된다는 특징이 있지만, BottomSheetDialogFragment를 사용하면서 안에 스크롤할 수 있는 뷰를 넣겠다면 감수해야 하는 특징이다.

반응형
Comments