본문 바로가기
오늘은 뭘 배울까?/Android

Bottom Sheet Dialog 예제 : Modal, Persistent, 모서리 둥글게 등

by Kim Juhwan 2022. 9. 14.

1. Bottom Sheet Dialog
   1-1. 개념
   1-2. 종류와 차이
2. Modal Bottom Sheet
   2-1. 기본 예제
   2-2. 모서리가 둥근 예제
   2-3. 버튼이 있는 예제
   2-4. Modal 위에 텍스트
   2-5. RadioButton 사용 예제
3. Persistent Bottom Sheet
   3-1. 기본 예제
   3-2. 활용 예제
4. 알아두면 좋은 정보
   4-1. Expanding Bottom Sheet
   4-2. UI/UX
5. 전체 코드

 

 

 


 

 

1. Bottom Sheet Dialog

1-1. 개념

Bottom Sheet Dialog의 예시

 

앱을 사용하다 보면 위 사진처럼 아래에서 빼꼼하고 나오는 창이 있다.

화면 가운데에 뜨는 Dialog와 별도로 이 창의 정식 명칭은 Bottom Sheet Dialog이다.

개인적으로 앱 처음 들어갔을 때 광고나 푸시 알림을 허용해주세요! 같은 용도로 많이 봤던 것 같다.

 

1-2. 종류와 차이

  • Modal Bottom Sheet

Modal Bottom Sheet 예제들 (G마켓, 인터파크, 위메프 순)

 

  • Persistent Bottom Sheet

Persistent Bottom Sheet 예제들 (카카오맵, 구글 지도, 음악 앱 순)

 

 

Bottom Sheet Dialog는

Modal Bottom Sheet와 Persistenet Bottom Sheet 이렇게 2가지 종류로 나뉜다.

겉으로만 봤을 때는 무슨 차이가 있는지 가늠이 가지 않으니 다음 표를 통해 차이를 알아보자.

 

Modal Bottom Sheet Persistent Bottom Sheet
인라인 메뉴나 간단한 대화 상자의 대안이다.
기존 콘텐츠와 상호 작용하려면
Modal Bottom Sheet를 종료해야 한다.
사용자가 기존 화면에 있는 콘텐츠와 상호작용 하면서
Persistent Bottom Sheet도 같이 볼 수 있다.
Activity나 Fragment에 속해있지 않으며
요구에 따라 동적으로 화면에 나타난다.
(마치 토스트 메시지 처럼)
Activity나 Fragment 레이아웃에 속해있다.
(항상 앱의 하단에 표시되어 컨텐츠를 보여줌)
다른 컴포넌트보다 높은 elevation(높이)를 가져
사용자의 주목을 끌도록 유도한다.
화면에 존재하는 다른 컴포넌트와
동일한 elevation(높이)를 가진다.
Bottom Sheet Dialog Fragment를 상속받아 사용한다. 화면 내 다른 컴포넌트와 상호작용하기 위해
CoordinatorLayout을 최상단 레이아웃으로 사용한다.
Dialog를 열고 닫는 것 밖에 할 수 없다. 더 많은 정보를 얻기 위해 위로 드래그를
간략한 정보를 얻기 위해서는 아래로 드래그를 한다.
따로 상태가 없다.
(열고 닫기가 끝)
접기, 펼치기, 드래그, 숨기기, 고정과 같은 상태를
컨트롤 할 수 있다. (리스너도 있다)

 

영문 문서를 보고 번역했더니 뭔가 조금 어색한 것 같아서 추가 설명을 하자면

우선, Modal은 다이얼로그를 종료해야만 그 뒤에 있는 화면을 사용할 수 있다.

(그래서 Modal 뒤쪽이 불투명한 회색인 것이 특징이다)

반면 Persistent는 위쪽에 있는 화면도 사용하면서 Persistent도 동시에 사용이 가능하다.

 

또, Modal은 토스트 메시지와 같아서 아무 곳에서나 띄울 수 있다.

Persistent는 그 Activity(혹은 Framgent) 내에 구현한 하나의 View이기 때문에 그 화면에서만 띄울 수 있다.

 

그리고 Modal은 열고 닫기가 끝인데

Persistent는 살짝 접을 수도 있고 많이 펼칠 수도 있고 여러 상태 조절이 가능하다.

 

자, 말로만 들어서는 감이 잘 오지 않을 테니 이제 예제와 함께 알아보자.

 

예제와 필요한 코드의 수가 많기 때문에
설명이 필요한 부분만 포스팅에 작성해두었습니다.
전체 코드가 필요하신 분은 [목차 5]에 있는 Repository를 참고해주세요. 

2. Modal Bottom Sheet

2-1. 기본 예제

텍스트만 띄운 Modal 예제

 

// ModalBottomSheet.kt
class ModalBottomSheet : BottomSheetDialogFragment()  {
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        super.onCreateView(inflater, container, savedInstanceState)
        return inflater.inflate(R.layout.bottom_sheet_modal, container, false)
    }

    companion object {
        const val TAG = "BasicBottomModalSheet"
    }
}
    // MainActivity에 작성하여 호출해주세요.
    private fun modalBottomSheet() {
        val modal = ModalBottomSheet()
        modal.show(supportFragmentManager, ModalBottomSheet.TAG)
    }

Modal의 가장 기본적인 사용 방법이다.

BottomSheetDialogFragment를 상속받아 띄우고 싶은 xml을 inflate 해주면 된다.

 

2-2. 모서리가 둥근 예제

모서리가 둥근 Modal 예제

 

<!-- white_round_top_border_20.xml -->
<!-- drawable 폴더에 만들어 주세요 -->
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="@color/white" />
    <corners
        android:topLeftRadius="20dp"
        android:topRightRadius="20dp" />
</shape>
<!-- res - values - themes - themes.xml -->
<!-- Modal에 둥근 모서리를 주기 위해 style 정의해주세요 -->
<resources>
    ...
    
    <style name="RoundCornerBottomSheetDialogTheme" parent="Theme.Design.Light.BottomSheetDialog">
        <item name="bottomSheetStyle">@style/RoundCornerModalStyle</item>
    </style>

    <style name="RoundCornerModalStyle" parent="Widget.Design.BottomSheet.Modal">
        <item name="android:background">@drawable/white_round_top_border_20</item>
    </style>
    
    ...
</resources>
    // MainActivity에 작성하여 호출해주세요.
    private fun modalWithRoundCorner() {
        val modal = ModalBottomSheet()
        modal.setStyle(DialogFragment.STYLE_NORMAL, R.style.RoundCornerBottomSheetDialogTheme)
        modal.show(supportFragmentManager, ModalBottomSheet.TAG)
    }

Modal에 둥근 모서리를 주는 UI는 굉장히 흔하다.

둥근 모서리를 주기 위해서는 drawable을 만들고 이를 사용하는 style을 만들어

코드 상에서 setStyle로 theme를 지정해주면 된다.

 

xml 자체에서 할 수 있으면 참 좋겠으나 나도 여러 방면으로 시도해봤지만 안된다 🤔

 

2-3. 버튼이 있는 예제

버튼이 있는 Modal 예제

 

class ModalExample1 : BottomSheetDialogFragment()  {
    lateinit var binding: ModalExample1Binding

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

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

        binding.tvReject.apply {
            paintFlags = Paint.UNDERLINE_TEXT_FLAG // 밑줄
            setOnClickListener {
                dismiss()
            }
        }

        binding.ivClose.setOnClickListener {
            dismiss()
        }

        binding.btnEventOn.setOnClickListener {
            dismiss()
        }
    }

    companion object {
        const val TAG = "ModalExample1"
    }
}

버튼이 있는 경우는 일반 프래그먼트 사용하듯이 onViewCreated 안에서 처리해주면 된다.

Modal을 종료하고 싶을 땐 dismiss를 호출하면 된다.

 

xml 코드는 너무 긴 관계로 [목차 5]의 repository에서 확인할 수 있다.

 

2-4. Modal 위에 텍스트

Modal 위에 텍스트가 들어가는 예제

 

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    xmlns:tools="http://schemas.android.com/tools">

    <!-- 이 레이아웃은 여기서 투명하게 만들 필요가 없습니다 (애초에 불가능) -->
    <!-- 코드상에서 직접 style을 적용해 줄 거예요 -->
    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/constraintLayout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toTopOf="parent">

        <TextView
            android:id="@+id/tv_reject_today"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="16dp"
            android:text="오늘 하루 보지 않기"
            android:textColor="@color/white"
            tools:textColor="@color/black"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/tv_close"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="16dp"
            android:text="닫기"
            android:textColor="@color/white"
            tools:textColor="@color/black"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    </androidx.constraintlayout.widget.ConstraintLayout>

    <!-- 텍스트 아래에 들어갈 View의 background를 둥근 모서리로 지정해주세요 -->
    <!-- 들어갈 View가 여러 개라면 layout이나 cardView로 감싸 모서리를 둥글게 해주세요 -->
    <ImageView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:adjustViewBounds="true"
        android:background="@drawable/white_round_top_border_20"
        app:layout_constraintTop_toBottomOf="@+id/constraintLayout"
        app:srcCompat="@drawable/bg_banner" />

</androidx.constraintlayout.widget.ConstraintLayout>
<!-- res - values - themes - themes.xml -->
<!-- Modal의 배경을 투명하게 만들기 위해 style 정의해주세요 -->
<resources>
    ...
    
    <style name="TransParentBottomSheetDialogTheme" parent="Theme.Design.Light.BottomSheetDialog">
        <item name="bottomSheetStyle">@style/TransParentModalStyle</item>
    </style>

    <style name="TransParentModalStyle" parent="Widget.Design.BottomSheet.Modal">
        <item name="android:background">@android:color/transparent</item>
    </style>
    
    ...
</resources>
    // MainActivity에 작성하여 호출해주세요.
    private fun modalExample2() {
        val modal = ModalExample2()
        modal.setStyle(DialogFragment.STYLE_NORMAL, R.style.TransParentBottomSheetDialogTheme)
        modal.show(supportFragmentManager, ModalExample2.TAG)
    }

Modal 위에 텍스트를 올리는 UI도 자주 보여서 만들어 봤다.

인터넷에 예제가 있나 찾아봤지만 못 찾았고... 나름대로 연구해서 만들어봤는데 정석 방법인지는 모르겠다.

style로 투명한 배경을 주는 방법이고 이유는 모르겠으나 xml에서 직접 background를 투명하게 주면 먹히지 않는다.

style로 적용을 해줘야한다.

 

Activity와 Framgnet 코드는 생략

 

2-5. RadioButton 사용 예제

Modal과 RadioButton을 사용한 예제

 

// 선택한 옵션 값을 유지하기 위해 싱글톤으로 만듦
object ModalExample3 : BottomSheetDialogFragment() {
    lateinit var binding: ModalExample3Binding
    private val list = arrayListOf (
        Sort(R.drawable.ic_chart, "판매 인기 순"),
        Sort(R.drawable.ic_down, "낮은 가격 순"),
        Sort(R.drawable.ic_up, "높은 가격 순"),
        Sort(R.drawable.ic_star, "상품평 많은 순"),
        Sort(R.drawable.ic_cumulative_sales, "누적 판매 순")
    )
    private val sortAdapter = SortAdapter(list)
    const val TAG = "ModalExample3"

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

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

        binding.rvSort.apply {
            layoutManager = LinearLayoutManager(context)
            adapter = sortAdapter

            sortAdapter.setItemClickListener(object : SortAdapter.ItemClickListener{
                override fun onClick(position: Int) {
                    // 선택한 옵션(position)으로 원하는 작업
                    dismiss()
                }
            })
        }

        binding.ivClose.setOnClickListener {
            dismiss()
        }
    }
}

Modal과 radiobutton을 사용하는 경우도 쇼핑몰 앱 등에서 종종 볼 수 있다.

사용 방법은 특별할 건 없고 recylcerview랑 radiobutton 사용법만 추가된 형태이다.

adapter와 xml 등의 코드는 repository에서 확인할 수 있다.

 

3. Persistent Bottom Sheet

3-1. 기본 예제

Persistent 기본 예제

 

class PersistentActivity : AppCompatActivity() {
    private lateinit var binding: ActivityPersistentBinding
    lateinit var behavior: BottomSheetBehavior<LinearLayout>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityPersistentBinding.inflate(layoutInflater)
        setContentView(binding.root)

        initEvent()
    }

    private fun initEvent() {
        persistentBottomSheetEvent()

        binding.btnCollapsed.setOnClickListener {
            behavior.state = BottomSheetBehavior.STATE_COLLAPSED
        }

        binding.btnExpanded.setOnClickListener {
            behavior.state = BottomSheetBehavior.STATE_EXPANDED
        }
    }

    private fun persistentBottomSheetEvent() {
        behavior = BottomSheetBehavior.from(binding.persistentBottomSheet)
        behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
            override fun onSlide(bottomSheet: View, slideOffset: Float) {
                // 슬라이드 되는 도중 계속 호출
                // called continuously while dragging
                Log.d(TAG, "onStateChanged: 드래그 중")
            }
            override fun onStateChanged(bottomSheet: View, newState: Int) {
                when(newState) {
                    BottomSheetBehavior.STATE_COLLAPSED-> {
                        Log.d(TAG, "onStateChanged: 접음")
                    }
                    BottomSheetBehavior.STATE_DRAGGING-> {
                        Log.d(TAG, "onStateChanged: 드래그")
                    }
                    BottomSheetBehavior.STATE_EXPANDED-> {
                        Log.d(TAG, "onStateChanged: 펼침")
                    }
                    BottomSheetBehavior.STATE_HIDDEN-> {
                        Log.d(TAG, "onStateChanged: 숨기기")
                    }
                    BottomSheetBehavior.STATE_SETTLING-> {
                        Log.d(TAG, "onStateChanged: 고정됨")
                    }
                }
            }
        })
    }

    private val TAG = "PersistentActivity"
}
<?xml version="1.0" encoding="utf-8"?>
<!-- Persistent Bottom Sheet과의 상호작용을 위해 CoordinatorLayout 사용(필수) -->
<androidx.coordinatorlayout.widget.CoordinatorLayout
    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:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".persistent.PersistentActivity">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="100dp">

        <Button
            android:id="@+id/btn_collapsed"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:layout_margin="20dp"
            android:text="collapsed" />

        <Button
            android:id="@+id/btn_expanded"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:layout_margin="20dp"
            android:text="expanded" />
    </LinearLayout>

    <!-- Persistent Bottom Sheet -->
    <!-- behavior 속성을 여기서 적용합니다 -->
    <LinearLayout
        android:id="@+id/persistent_bottom_sheet"
        android:layout_width="match_parent"
        android:layout_height="250dp"
        android:background="#ebebeb"
        android:orientation="vertical"
        android:padding="16dp"
        app:behavior_hideable="false"
        app:behavior_peekHeight="100dp"
        app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">

        <!-- persistent bottom sheet의 Content -->
        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="🔼 Pull me up! 🔼"
            android:textSize="24sp"
            android:gravity="center"/>

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Persistent Bottom Sheet"
            android:textSize="24sp"
            android:paddingTop="100dp"
            android:gravity="center"/>

    </LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

설명은 주석으로 다 달아두었고... 유의할 점 몇 가지 적어보자면

Modal과 다른 점은 별도의 BottomSheetDialogFragment를 생성하는 게 아니라

Activity내에 다른 view들과 함께 들어간다는 것.

그래서 다른 view들과의 상호작용을 위해 Coordinator로 감싸야한다는 것

Persistent Bottom Sheet이라는 게 별게 아니라 LinearLayout이든 뭐든 거기에 behavior 속성을 주면 된다는 것

app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" 는 꼭 적어야 한다는 것

이 점들을 유의하자.

 

그리고 여러 behavior 속성을 사용할 수 있는데 그 종류는 다음과 같다.

속성 설명 기본 값
behavior_hideable 아래로 드래그 시 view를 숨길지 false
behavor_skipCollapsed view를 숨길 때 접히는 상태를 무시할 지 false
behavior_draggable 드래그로 view를 펼치고 접을지 true
behavior_fitToContents 펼쳐진 뷰의 높이가 content를 감쌀지 true
behavior_halfExpandedRatio 절반만 펼쳐졌을 경우 뷰의 높이 0.5
behavior_expandedOffset 완전히 펼쳐진 상태일 때 뷰의 오프셋 0dp
behaviro_peekHeight 뷰가 접힌 상태에서의 높이 auto

 

3-2. 활용 예제

네이버 지도를 모방해본 Persistent 예제

 

Persistent를 활용해본 예제이다.

네이버 지도와 똑같이 Bottom Sheet를 펼쳤을 때 상세 페이지로 넘어가게 구현했다.

딱히 어려운 기술은 없고 단순히 코드만 길어서 따로 여기에 코드를 첨부하진 않았다.

Repository에서 자세한 코드를 확인할 수 있다.

 

4. 알아두면 좋은 정보

4-1. Expanding Bottom Sheet

Expanding Bottom Sheet 예제

 

사실 Google Material 문서에서는 Bottom Sheet Dialog의 종류를 하나 더 소개하고 있다.

하지만 개인적으로 사용해본 경험이 거의 없고 사용 빈도가 낮은 것 같아 포스팅에서 소개하지 않았다.

만약 자세히 알아보고 싶다면 여기여기를 참고하면 된다. (공식문서 링크임)

 

그리고 블로거분들 중에서 Expanding Bottom Sheet와 비슷한 느낌으로 구현하신 분도 계셔서 링크를 남겨본다.

 

4-2. UI/UX

Modal 사용시 권장하는 dimens 규격
google material에서는 16:9의 비율을 넘지 말라고 권장하고 있다.

 

Google Material 공식문서에는 Bottom Sheet의 UI/UX에 대한 많은 정보를 얻을 수 있다.

이렇게는 디자인하지 마세요, 이런 사이즈로 제작하세요와 같은 가이드라인을 제시한다.

앱 개발자로서 읽어보면 좋은 정보들이니 읽어보길 추천한다.

 

5. 전체 코드

 

GitHub - juhwankim-dev/SelfStudy: 코틀린으로 공부한 것들을 올리는 공간입니다.

코틀린으로 공부한 것들을 올리는 공간입니다. Contribute to juhwankim-dev/SelfStudy development by creating an account on GitHub.

github.com

 

전체 코드가 필요하신 분들을 위해 Git Repository를 공유합니다 😊

Bottom Sheet Dialog 폴더에서 총 7가지의 예제를 확인하실 수 있습니다!

 


💡 느낀 점

  • 사실 정리하기 전에는 Modal 종류 하나만 존재하는 줄 알았다. Persistent Bottom Sheet라는 것도 있었군...
  • 나는 여태까지 Modal과 Persistent의 사용법을 혼용해서 사용하고 있었다. ㅠㅠ... 나는 멍청이~
  • 주로 쓰이는 커스텀 예제들을 이번에 다 만들어놔서 앞으로 해커톤 할 때 더 빠르게 개발할 수 있을 것 같다.
  • Google Material을 보면 Modal 사용 시 하지 말아야 할 행동으로 뒷 배경을 투명하게 하는 것(불투명하게 하지 않는 것)을 소개하고 있다. 근데 페이코 앱을 보면... 이를 어기고 있다. 기본 값이 아닌 추가적인 코드를 작성해야 바꿀 수 있었을 텐데 굳이 바꾼 이유가 뭘까??? UI/UX도 진짜 제대로 공부해보고 싶다.

📘 참고한 자료


 

 

반응형

댓글