[Android] Dialog - 대화상자

대화상자 Dialog 는 사용자의 주의를 끌고 입력을 받는 데 사용되며 사용자의 선택을 받거나 중요한 정보를 보여줄 때도 유용하다.

범죄 발생일자를 선택하는 대화상자

위의 대화상자는 AlertDialog의 서브 클래스인 DatePickerDialog의 인스턴스다. DatePickerDialog는 사용자가 날짜를 선택할 수 있게 해주며 사용자 선택을 알아내기 위해 구현하는 리스너 인터페이스를 제공한다. AlertDialog는 다목적의 Dialog 서브 클래스이며 커스텀 대화상자를 생성할 때 흔히 사용한다.

DialogFragment 생성하기

DatePickerDialog를 화면에 보여줄 때는 Fragment의 서브 클래스인 DialogFragment 인스턴스에 포함시키는 것이 좋다.

DialogFragment 없이 DatePickerDialog를 보여줄 수 있지만, FragmentManager로 DatePickerDialog를 관리하는 것이 유연성이 좋다. 그냥 DatePickerDialog만 사용하면 장치가 회전할 때 화면에서 사라지지만, DatePickerDialog가 프래그먼트에 포함되면 장치 회전 후에도 대화상자가 다시 생성되어 화면에 다시 나타난다.

MainActivity에 의해 호스팅되는 두 프래그먼트의 객체 다이어그램

할 일은 다음과 같다.

  • DatePickerFragment 클래스 생성한다.
  • DatePickerDialog 인스턴스를 생성해 대화상자를 만든다.
  • FragmentManager를 통해 대화상자를 화면에 보여준다.

DialogFragment 생성하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
...
import androidx.fragment.app.DialogFragment
import java.util.*

class DatePickerFragment: DialogFragment() {

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val calendar = Calendar.getInstance()
val initialYear = calendar.get(Calendar.YEAR)
val initialMonth = calendar.get(Calendar.MONTH)
val initialDay = calendar.get(Calendar.DAY_OF_MONTH)

return DatePickerDialog(
requireContext(),
null,
initialYear,
initialMonth,
initialDay
)
}
}

DatePickerDialog 생성자는 여러 개의 인자를 받는다. 첫 번째는 이 뷰에서 필요한 리소스를 사용하려면 지정해야 하는 Context 객체다. 두 번째는 날짜 리스너로 본문의 뒤에서 추가한다. 나머지 세 개는 년, 월, 일의 초기값으로, 일단 오늘 날짜로 초기화한다.

DialogFragment 보여주기

다른 모든 프래그먼트처럼 DialogFragment의 인스턴스도 호스팅 액티비티의 FragmentManager가 관리한다.

FragmentManager에 추가되는 DialogFragment를 화면에 나타나게 하려면 다음 프래그먼트 인스턴스 함수 중 하나를 호출하면 된다.

1
2
show(manager: FragmentManager, tag: String)
show(transaction: FragmentTransaction, tag: String)

String 인자는 FragmentManager의 리스트에서 DialogFragment를 고유하게 식별할 때 사용된다.

FragmentManager나 FragmentTransaction 중 어떤 것을 사용하는가는 프로그래머에게 달렸다.

  • FragmentTransaction을 인자로 전달할 때는 직접 트랜잭션을 생성한 후 커밋해야 한다.
  • FragmentManager를 인자로 전달하면 트랜잭션이 자동으로 생성되어 커밋된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
private const val TAG = "CrimeFragment"
private const val ARG_CRIME_ID = "crime_id"
private const val DIALOG_DATE = "DialogDate"

class CrimeFragment : Fragment() {
...
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
...
solvedCheckBox = view.findViewById(R.id.crime_solved) as CheckBox

// dateButton.apply {
// text = crime.date.toString()
// isEnabled = false
// }

return view
}

...
override fun onStart() {
...
solvedCheckBox.apply {
...
}

dateButton.setOnClickListener {
DatePickerFragment().apply {
show(this@CrimeFragment.parentFragmentManager, DIALOG_DATE)
}
}
}

this@CrimeFragment는 DatePickerFragment가 아닌 CrimeFragment로부터 requireFragmentManager()를 호출하기 위해 필요하다. 여기서는 apply 블록 내부의 this가 DatePickerFragment를 참조하므로 this 다음에 @CrimeFragment를 지정했다.

apply 블록 내부의 this가 DatePickerFragment를 참조

DialogFragment의 show(FragmentManager, String) 함수에서 첫 번째 인자인 프래그먼트매니저 인스턴스 참조는 null 값이 될 수 없는 타입니다. 그런데 Fragment.fragmentManager 속성은 null 값이 될 수 있는 타입이므로 첫 번재 인자로 전달 할 수 없다. 따라서 여기서는 Fragment의 getParentFragmentManager() 함수를 사용했는데 이 함수는 null이 아닌 FragmentManager 인스턴스를 반환하기 때문이다. 만일 Fragment.requireFragmentManager()가 호출되었는데 프래그먼트의 fragmentManager 속성이 null이면 IllegalStateException이 발생한다. 이 예외는 해당 프래그먼트와 연관된 프래그먼트 매니저가 없음을 나타낸다.

날짜 선택 대화 상자

두 프래그먼트 간의 데이터 전달하기

인텐트 엑스트라를 사용한 두 액티비티 간의 데이터 전달, 콜백 인터페이스를 사용한 프래그먼트와 액티비티 간의 데이터 전달, 프래그먼트 인자를 사용한 액티비티로부터 프래그먼트로의 데이터 전달에 관해서는 다른 글에 정리되어 있다.

본문에서는 같은 액티비티에 의해 호스팅되는 두 프래그먼트, 즉 CrimeFragment와 DatePickerFragment 간의 데이터 전달이 필요하다.

DatePickerFragment에 범죄 발생일자 (Crime 객체의 date)를 전달하기 위해 newInstance(Date) 함수를 작성하고, 이 함수의 인자로 전달된 발생일자를 DatePickerFragment의 프래그먼트 인자로 전달한다.

그다음에 대화상자에서 사용자가 선택한 날짜를 DatePickerFragment에서 CrimeFragment로 돌려준다. 더불어 사용자가 선택한 날짜를 인자로 받는 콜백 인스턴스 함수를 DatePickerFragment에 선언하기 위해 CrimeFragment가 모델 계층 (Crime 객체)과 자신의 뷰 (범죄 상세 내역 화면)를 변경한다.

CrimeFragment와 DatePickerFragment 간의 처리 흐름

DatePickerFragment에 데이터 전달하기

DatePickerFragment에 현재의 범죄 발생일자를 전달하고자 여기서는 DatePickerFragment의 인자 번들에 해당 날짜를 저장한다.

일반적으로 프래그먼트 인자의 생성과 설정은 프래그먼트 생성자를 대체하는 newInstance(...) 함수에서 처리한다. 따라서 DatePickerFragment.kt에서 동반 객체 내부에 newInstance(Date) 함수를 추가하면 된다.

newInstance(Date) 함수를 추가하기 (DatePickerFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private const val ARG_DATE = "date"

class DatePickerFragment: DialogFragment() {

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
...
}

companion object {
fun newInstance(date: Date): DatePickerFragment {
val args = Bundle().apply {
putSerializable(ARG_DATE, date)
}

return DatePickerFragment().apply {
arguments = args
}
}
}
}

여기서 arguments는 DatePickerFragment의 속성 (최상위 슈퍼 클래스인 Fragment로부터 DialogFragment로 상속되고 다시 DatePickerFragment로 상속됨)이며, 프래그먼트 인자를 갖는다.

그다음에 CrimeFragment에서 DatePickerFragment의 생성자 호출 코드를 삭제하고, DatePickerFragment.newInstance(Date)

newInstance(...) 호출 추가하기 (CrimeFragment.kt)

1
2
3
4
5
6
7
8
9
override fun onStart() {
...
dateButton.setOnClickListener {
// DatePickerFragment().apply {
DatePickerFragment.newInstance(crime.date).apply {
show(this@CrimeFragment.parentFragmentManager, DIALOG_DATE)
}
}
}

DatePickerFragment는 Date 객체의 데이터를 사용해서 DatePickerDialog를 초기화해야 한다. DatePickerDialog를 초기화하려면 월, 일, 년의 정수 값들이 필요하다. 그러나 Date 객체는 타임스탬프 형태이므로 이런 형식의 정수를 제공할 수 없다.

따라서 필요한 정수들을 얻으려면 Date 객체를 사용해서 Calendar 객체를 생성해야 한다. 그렇게 해야 이 Calendar 객체로부터 필요한 형태의 정수를 얻을 수 있다.

프래그먼트 인자로부터 얻은 Date 객체의 값을 Calendar 객체로 옮긴 후 DatePickerDialog를 초기화하는 코드를 DatePickerFragment.kt의 onCreateDialog(Bundle?)에 추가한다.

DatePickerDialog 초기화하기 (DatePickerFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class DatePickerFragment: DialogFragment() {

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val date = arguments?.getSerializable(ARG_DATE) as Date // ①
val calendar = Calendar.getInstance()
calendar.time = date // ②
val initialYear = calendar.get(Calendar.YEAR)
val initialMonth = calendar.get(Calendar.MONTH)
val initialDay = calendar.get(Calendar.DAY_OF_MONTH)

return DatePickerDialog(
requireContext(),
null,
initialYear,
initialMonth,
initialDay
)
}
...
}

이제는 CrimeFragment가 DatePickerFragment에 범죄 발생일자를 전달할 수 있다.

CrimeFragment로 데이터 반환하기

CrimeFragment가 DatePickerFragment로부터 사용자가 선택한 날짜를 돌려받으려면 두 프래그먼트 간의 관계를 계속해서 유지하고 관리하는 방법이 필요하다.

액티비티의 경우에 startActivityForResult(...) 함수를 호출하면 ActivityManager가 부모-자식 액티비티 관계를 계속해서 유지하고 관리한다. 따라서 자식 액티비티가 끝나면 이것의 결과를 어떤 액티비티가 받아야 하는지 ActivityManager가 안다.

대상 프래그먼트 설정하기

CrimeFragment를 DatePickerFragment의 대상 프래그먼트 target fragment 로 만들면 액티비티의 경우와 유사한 연결을 만들 수 있다. 그리고 CrimeFragment 인스턴스와 DatePickerFragment 인스턴스 모두가 안드로이드 운영체제에 의해 소멸되었다가 다시 생성되더라도 두 프래그먼트 간의 연결은 자동으로 복구된다. 이렇게 하려면 다음 Fragment 함수를 호출하면 된다.

1
setTargetFragment(fragment: Fragment, requestCode: Int)

이 함수는 대상이 되는 프래그먼트와 요청 코드를 인자로 받는데, 이 요청 코드는 startActivityForResult(...)의 인자로 전달되는 것과 같은 의미를 갖는다.

이때 FragmentManager는 대상 프래그먼트와 요청 코드를 계속 관리한다. 대상을 설정했던 프래그먼트의 targetFragment와 targetRequestCode 속성을 사용하면 이 정보를 알 수 있다.

이제 CrimeFragment.kt에서 요청 코드의 상수를 정의하고 DatePickerFragment 인스턴스의 대상 프래그먼트로 CrimeFragment를 설정한다.

대상 프래그먼트 설정하기 (CriemFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...
private const val DIALOG_DATE = "DialogDate"
private const val REQUEST_DATE = 0

class CrimeFragment : Fragment() {
...
override fun onStart() {
...
dateButton.setOnClickListener {
DatePickerFragment.newInstance(crime.date).apply {
setTargetFragment(this@CrimeFragment, REQUEST_DATE)
show(this@CrimeFragment.parentFragmentManager, DIALOG_DATE)
}
}
}

대상 프래그먼트로 데이터 전달하기

CrimeFragment와 DatePickerFragment가 연결되었으니 CrimeFragment로 데이터(사용자가 선택한 날짜)를 반환해야 한다. 여기서는 DatePickerFragment에 콜백 인터페이스를 생성한다. 이때 이 콜백 인터페이스는 CrimeFragment가 구현한다.

우선 DatePickerFragment에 onDateSelected()라는 하나의 함수를 갖는 콜백 인터페이스를 생성한다.

콜백 인터페이스 생성하기 (DatePickerFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
class DatePickerFragment: DialogFragment() {

interface Callbacks {
fun onDateSelected(date: Date)
}

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
...
}
...
}

다음으로 Callbacks 인터페이스를 CrimeFragment에 구현한다. 이때 onDateSelected(Date)에서는 인자로 전달된 Date 객체를 Crime 객체의 date 속성(CrimeFragment의 crime 속성이 참조함)에 설정하고 UI를 변경한다.

콜백 인터페이스 구현하기 (CrimeFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
class CrimeFragment : Fragment(), DatePickerFragment.Callbacks {
...
override fun onStop() {
...
}

override fun onDateSelected(date: Date) {
crime.date = date
updateUI()
}
...
}

이제는 CrimeFragment 사용자가 선택한 날짜를 처리할 수 있으므로 이 날짜를 DatePickerFragment가 전달해야 한다. DatePickerDialog의 리스너를 DatePickerFragment에 추가하면, 이 리스너에서는 사용자가 선택한 날짜를 CrimeFragment에 전달한다.

사용자가 선택한 날짜 전달하기 (DatePickerFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class DatePickerFragment : DialogFragment() {
...
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dateListener = DatePickerDialog.OnDateSetListener { _: DatePicker, year: Int, month: Int, day: Int ->

val resultDate: Date = GregorianCalendar(year, month, day).time

targetFragment?.let { fragment ->
(fragment as Callbacks).onDateSelected(resultDate)
}
}

val date = arguments?.getSerializable(ARG_DATE) as Date
...

return DatePickerDialog(
requireContext(),
// null,
dateListener,
initialYear,
initialMonth,
initialDay
)
}
...
}

OnDateSetListener는 사용자가 선택한 날짜를 받는데 사용된다. 첫 번째 매개변수는 결과가 산출되는 DatePicker 객체이며, 여기서는 사용하지 않으므로 밑줄 _을 지정하였다.(코틀린에서 사용되지 않은 매개변수를 나타낼 때 밑줄을 사용)

선택된 날짜는 년, 월, 일 형식으로 제공된다. 그러나 이 값들을 Date 타입으로 CrimeFragment에 전달해야 하므로 GregorianCalendar의 인자로 이 값들을 전달한 후 time 속성을 사용해서 Date 객체를 얻는다.

targetFragment 속성은 DatePickerFragment와 연관된 프래그먼트(여기선 CrimeFragment) 인스턴스 참조를 갖는다. 이 속성은 null 값을 가질 수 있으므로 null에 안전한 let 블록이 사용되었다. let 블록에서는 targetFragment 속성이 참조하는 프래그먼트 인스턴스의 타입을 Callbacks 인터페이스 타입으로 변환한 후, 새로운 날짜를 인자로 전달해 onDateSelected() 함수를 호출한다. 따라서 CrimeFragment에 구현된 onDateSelected() 함수가 호출되어 실행되므로, 사용자가 선택한 날자가 CrimeFragment에 전달될 수 있다.

댓글