Friday, May 17, 2019

안드로이드 다이얼로그(Dialog), Activity로 똑똑하게 만드는 비법 공개

안드로이드 앱을 개발하다 보면 사용자에게 간단한 알림을 보여주거나, 특정 입력을 받거나, 선택지를 제공해야 하는 경우가 빈번하게 발생합니다. 이때 가장 먼저 떠오르는 것이 바로 다이얼로그(Dialog)입니다. AlertDialog, DatePickerDialog, 혹은 커스텀 레이아웃을 적용한 DialogFragment는 안드로이드 개발자에게 매우 친숙한 도구입니다.

하지만 다이얼로그의 내용이 단순한 텍스트와 버튼 몇 개를 넘어설 때, 문제는 복잡해지기 시작합니다. 다이얼로그 내부에 RecyclerView를 넣어야 하거나, 여러 단계의 입력을 받아야 하거나, 네트워크 통신 후 결과를 UI에 반영해야 하는 등 복잡한 로직이 포함되면 DialogFragment의 코드는 금세 비대해지고 생명주기 관리는 까다로워집니다. 특히 다이얼로그와 이를 호출한 Activity 또는 Fragment 간의 데이터 통신은 콜백 인터페이스 구현 등으로 인해 번거로운 작업이 되기도 합니다.

만약, 우리가 이미 익숙한 Activity의 모든 강력한 기능(생명주기, 레이아웃 관리, 인텐트를 통한 데이터 전달 등)을 그대로 사용하면서, 화면에는 마치 다이얼로그처럼 보이게 할 수 있다면 어떨까요? 이 글에서는 바로 그 방법을 심도 있게 파헤쳐 봅니다. AndroidManifest.xml의 테마 설정을 활용해 Activity를 다이얼로그처럼 만드는, 놀랍도록 간단하면서도 강력한 기법을 소개합니다. 이 방법을 통해 여러분의 다이얼로그 구현 코드는 훨씬 더 깔끔하고 직관적이며, 유지보수가 용이해질 것입니다.


왜 Activity를 Dialog처럼 사용해야 하는가?

본격적인 구현에 앞서, 왜 굳이 Activity를 다이얼로그로 위장시켜야 하는지 그 장점을 명확히 짚고 넘어가겠습니다. 이 접근 방식이 빛을 발하는 순간들을 이해하면, 언제 이 기술을 적용해야 할지 판단하는 데 큰 도움이 될 것입니다.

  1. 단순하고 직관적인 구현: DialogFragment의 생명주기(onAttach, onCreateView, onViewCreated 등)와 부모 프래그먼트/액티비티와의 상호작용을 신경 쓸 필요가 없습니다. 일반적인 Activity를 만들고, 레이아웃을 설정하고, 로직을 구현하는 익숙한 방식으로 개발할 수 있습니다.
  2. 자유로운 레이아웃 설계: 복잡한 UI를 구성하기에 Activity만큼 좋은 환경은 없습니다. ConstraintLayout을 활용한 정교한 레이아웃, RecyclerViewViewPager2와 같은 복잡한 뷰 그룹의 추가, 심지어 내부에 여러 Fragment를 호스팅하는 것까지 모두 가능합니다. 다이얼로그의 한계를 뛰어넘는 풍부한 사용자 경험을 제공할 수 있습니다.
  3. 간편한 데이터 교환: Activity 간 데이터 교환은 안드로이드의 기본 중의 기본입니다. Intent에 데이터를 담아 startActivity()로 전달하고, 결과를 받을 때는 Activity Result API (최신 권장 방식)나 startActivityForResult() (구 방식)를 사용하면 됩니다. 복잡한 콜백 인터페이스를 설계하고 구현하는 과정이 생략되어 코드의 결합도가 낮아지고 가독성이 높아집니다.
  4. 독립성과 재사용성: 다이얼로그 역할을 하는 Activity는 그 자체로 하나의 독립된 컴포넌트입니다. 특정 ActivityFragment에 종속되지 않으므로, 앱의 어느 곳에서든 필요할 때 재사용하기가 매우 용이합니다.
  5. 명확한 책임 분리 (SRP): 하나의 거대한 DialogFragment가 모든 것을 처리하는 대신, 다이얼로그의 UI와 로직은 독립된 Activity가 전담하게 됩니다. 이는 단일 책임 원칙(Single Responsibility Principle)에 부합하며, 코드의 유지보수성과 테스트 용이성을 크게 향상시킵니다.

물론, "OK", "Cancel" 버튼만 있는 간단한 확인 다이얼로그에까지 이 방법을 쓰는 것은 과할 수 있습니다. 하지만 여러분이 구현하려는 다이얼로그가 단순한 알림창 이상의 기능을 담고 있다면, 이 'Activity-as-Dialog' 기법은 최고의 선택지가 될 것입니다.


Step-by-Step: Activity를 Dialog로 변신시키기

이제 이론을 넘어 실제 코드로 이 마법 같은 기법을 구현해 보겠습니다. 최종 목표는 사용자에게 메시지를 보여주고 '확인' 또는 '취소' 버튼을 통해 결과를 반환하는 간단한 커스텀 다이얼로그를 Activity로 만들어보는 것입니다.

1단계: 다이얼로그 Activity를 위한 레이아웃 XML 생성하기

가장 먼저 다이얼로그처럼 보일 화면의 레이아웃을 디자인합니다. 일반적인 액티비티 레이아웃을 만드는 것과 완전히 동일합니다. res/layout/activity_custom_dialog.xml 파일을 생성하고 아래와 같이 코드를 작성합니다.


<androidx.constraintlayout.widget.ConstraintLayout 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="wrap_content"
    android:layout_margin="16dp"
    android:background="@drawable/dialog_background"
    android:padding="24dp"
    tools:context=".CustomDialogActivity">

    <TextView
        android:id="@+id/tv_dialog_title"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:text="알림"
        android:textColor="@color/black"
        android:textSize="20sp"
        android:textStyle="bold"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/tv_dialog_message"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:text="이 작업은 되돌릴 수 없습니다. 정말로 진행하시겠습니까?"
        android:textColor="@color/black"
        android:textSize="16sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/tv_dialog_title" />

    <Button
        android:id="@+id/btn_dialog_negative"
        style="?attr/materialButtonOutlinedStyle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="24dp"
        android:text="취소"
        app:layout_constraintEnd_toStartOf="@id/btn_dialog_positive"
        app:layout_constraintHorizontal_bias="1"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/tv_dialog_message" />

    <Button
        android:id="@+id/btn_dialog_positive"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:text="확인"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="@id/btn_dialog_negative" />

</androidx.constraintlayout.widget.ConstraintLayout>

주요 포인트:

  • layout_width="match_parent", layout_height="wrap_content": 최상위 레이아웃의 높이를 내용물에 맞게 설정하여 다이얼로그처럼 보이게 합니다. 너비는 `match_parent`로 설정하되, 외부에서 `margin`을 주어 좌우 여백을 만듭니다.
  • android:background="@drawable/dialog_background": 다이얼로그의 모서리를 둥글게 처리하기 위해 별도의 `drawable` 파일을 사용했습니다. res/drawable/dialog_background.xml을 생성하고 아래와 같이 작성하세요.

<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="@android:color/white" />
    <corners android:radius="12dp" />
</shape>

2단계: Activity에 적용할 다이얼로그 테마 정의하기

이것이 바로 이 기법의 핵심입니다. res/values/styles.xml (또는 themes.xml) 파일을 열고, Activity를 다이얼로그처럼 보이게 만들어 줄 새로운 스타일(테마)을 정의합니다.

<?xml version="1.0" encoding="utf-8"?>
<resources>
    
    <style name="Theme.MyApp" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
        
    </style>

    
    <style name="Theme.MyApp.Dialog" parent="Theme.AppCompat.Dialog">
        
        <item name="android:windowIsFloating">true</item>
        
        <item name="android:windowNoTitle">true</item>
        
        <item name="android:windowBackground">@android:color/transparent</item>
        
        <item name="android:backgroundDimEnabled">true</item>
        
        <item name="android:backgroundDimAmount">0.6</item>
        
        <item name="android:windowCloseOnTouchOutside">true</item>
    </style>
</resources>

테마 속성 심층 분석:

  • parent="Theme.AppCompat.Dialog": 매우 중요합니다. 안드로이드 프레임워크가 제공하는 기본 다이얼로그 테마를 상속받습니다. 앱의 기본 테마가 AppCompat 계열(Theme.MaterialComponents... 등)을 사용한다면, 여기서도 반드시 AppCompat 계열의 다이얼로그 테마를 상속해야 호환성 문제가 발생하지 않습니다.
  • android:windowIsFloating: 이 속성을 true로 설정하면 Activity의 창(window)이 부모 창 위에 '떠 있는' 형태로 렌더링됩니다. 즉, 전체 화면을 차지하지 않고 독립된 작은 창으로 표시됩니다.
  • android:windowNoTitle: 액티비티 상단에 기본적으로 표시되는 타이틀 바(액션 바)를 제거합니다. 우리는 레이아웃에 직접 제목을 구현했으므로 필요 없습니다.
  • android:windowBackground: @android:color/transparent로 설정하여 액티비티 창 자체의 배경을 투명하게 만듭니다. 이렇게 해야 1단계에서 만든 레이아웃의 둥근 모서리와 그림자 효과 등이 올바르게 표시됩니다. 만약 이 속성을 설정하지 않으면, 투명해야 할 영역이 검게 표시될 수 있습니다.
  • android:backgroundDimEnabled: true로 설정하면 이 다이얼로그 액티비티 뒤에 있는 원래 액티비티 화면이 어두워져 사용자의 시선을 다이얼로그에 집중시키는 효과를 줍니다.

3단계: 다이얼로그 로직을 처리할 Activity 클래스 생성하기

이제 앞서 만든 레이아웃과 로직을 연결할 CustomDialogActivity.kt 파일을 생성합니다. 일반 AppCompatActivity를 상속받아 작성하면 됩니다.

import android.app.Activity
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.your.package.name.databinding.ActivityCustomDialogBinding // ViewBinding 사용

class CustomDialogActivity : AppCompatActivity() {

    private lateinit var binding: ActivityCustomDialogBinding

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

        // 외부에서 전달받은 데이터가 있다면 여기서 처리
        val title = intent.getStringExtra(EXTRA_TITLE)
        val message = intent.getStringExtra(EXTRA_MESSAGE)
        
        binding.tvDialogTitle.text = title ?: "알림"
        binding.tvDialogMessage.text = message ?: "기본 메시지입니다."

        // 확인 버튼 클릭 리스너
        binding.btnDialogPositive.setOnClickListener {
            val resultIntent = Intent().apply {
                putExtra(RESULT_EXTRA_DATA, "사용자가 확인을 눌렀습니다.")
            }
            setResult(Activity.RESULT_OK, resultIntent)
            finish() // 액티비티 종료
        }

        // 취소 버튼 클릭 리스너
        binding.btnDialogNegative.setOnClickListener {
            setResult(Activity.RESULT_CANCELED)
            finish() // 액티비티 종료
        }
    }

    // 외부에서 이 액티비티를 쉽게 호출하고 데이터를 전달하기 위한 companion object
    companion object {
        const val EXTRA_TITLE = "extra_title"
        const val EXTRA_MESSAGE = "extra_message"
        const val RESULT_EXTRA_DATA = "result_extra_data"
    }
}

코드 해설:

  • ViewBinding: 보일러플레이트를 줄이고 타입-세이프하게 뷰에 접근하기 위해 ViewBinding을 사용했습니다. (build.gradle 파일에 `buildFeatures { viewBinding = true }` 추가 필요)
  • 데이터 수신: onCreate에서 intent.getStringExtra()를 사용해 호출한 측에서 보낸 제목과 메시지 데이터를 받아 UI에 설정합니다. 이를 통해 동적으로 변하는 다이얼로그를 만들 수 있습니다.
  • 결과 반환: 사용자가 버튼을 클릭했을 때가 중요합니다.
    • setResult(Activity.RESULT_OK, resultIntent): 작업이 성공적으로 완료되었음을 알리고, 추가적인 결과 데이터를 Intent에 담아 설정합니다.
    • setResult(Activity.RESULT_CANCELED): 사용자가 작업을 취소했음을 알립니다.
  • finish(): 결과를 설정한 뒤에는 반드시 finish()를 호출하여 다이얼로그 액티비티를 화면에서 제거해야 합니다.
  • Companion Object: Intent의 Extra 키 값들을 상수로 관리하면 오타를 방지하고 코드의 가독성을 높일 수 있어 좋은 습관입니다.

4단계: AndroidManifest.xml에 Activity 등록 및 테마 적용

지금까지 만든 모든 조각을 하나로 합치는 마지막 단계입니다. AndroidManifest.xml 파일을 열고, 방금 만든 CustomDialogActivity를 등록하면서 2단계에서 정의한 다이얼로그 테마를 적용합니다.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.your.package.name">

    <application
        ...>
        
        <activity
            android:name=".MainActivity"
            ... />
            
        
        <activity
            android:name=".CustomDialogActivity"
            android:theme="@style/Theme.MyApp.Dialog"
            android:exported="false" />
            
    </application>
</manifest>

핵심은 `android:theme="@style/Theme.MyApp.Dialog"` 부분입니다. 이 한 줄의 코드가 일반 액티비티를 우리가 원하는 다이얼로그 스타일로 바꿔주는 역할을 합니다. `android:exported="false"`는 이 액티비티가 다른 앱에 의해 직접 호출될 수 없도록 설정하여 보안을 강화합니다.


Step-by-Step: 다이얼로그 Activity 호출하고 결과 받기 (최신 방식)

이제 완성된 다이얼로그 액티비티를 호출하고 그 결과를 받아 처리하는 방법을 알아봅시다. 과거에는 startActivityForResult()onActivityResult()를 사용했지만, 현재는 타입-세이프하고 생명주기를 고려한 Activity Result API 사용이 강력하게 권장됩니다.

호출하는 쪽 (e.g., MainActivity.kt)

메인 액티비티에서 버튼을 누르면 우리가 만든 커스텀 다이얼로그를 띄우고, 사용자의 선택('확인' 또는 '취소')에 따라 다른 동작을 하도록 구현해 보겠습니다.

import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.your.package.name.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    // 1. Activity Result Launcher 등록
    private val dialogResultLauncher = registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) { result ->
        // 3. 결과를 받았을 때 실행될 콜백
        if (result.resultCode == Activity.RESULT_OK) {
            // '확인'을 눌렀을 때
            val data: Intent? = result.data
            val returnString = data?.getStringExtra(CustomDialogActivity.RESULT_EXTRA_DATA)
            Toast.makeText(this, "결과: $returnString", Toast.LENGTH_SHORT).show()
        } else {
            // '취소'를 눌렀거나 뒤로가기로 닫았을 때
            Toast.makeText(this, "다이얼로그가 취소되었습니다.", Toast.LENGTH_SHORT).show()
        }
    }

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

        binding.btnShowDialog.setOnClickListener {
            // 2. 다이얼로그 액티비티 실행
            val intent = Intent(this, CustomDialogActivity::class.java).apply {
                putExtra(CustomDialogActivity.EXTRA_TITLE, "중요한 알림")
                putExtra(CustomDialogActivity.EXTRA_MESSAGE, "이 방법은 정말 강력하고 유용합니다. 동의하십니까?")
            }
            dialogResultLauncher.launch(intent)
        }
    }
}

Activity Result API 작동 방식:

  1. (Launcher 등록) registerForActivityResult(): 액티비티가 생성되는 시점(onCreate 이전)에 결과 처리를 위한 '런처(launcher)'를 등록합니다. 첫 번째 인자로는 어떤 종류의 작업을 할지 정의하는 Contract(여기서는 ActivityResultContracts.StartActivityForResult())를, 두 번째 인자로는 작업이 완료되고 결과를 받았을 때 실행될 람다(콜백)를 전달합니다. 이 콜백은 우리가 과거에 onActivityResult()에서 하던 로직을 담당합니다.
  2. (Activity 실행) launch(intent): 다이얼로그를 띄우고 싶을 때, 등록해둔 런처의 launch() 메서드를 호출하며 다이얼로그 액티비티를 시작할 Intent를 전달합니다.
  3. (결과 수신) 콜백 실행: CustomDialogActivitysetResult()를 호출하고 finish()되면, 시스템은 자동으로 1단계에서 등록한 콜백 람다를 실행합니다. 람다의 파라미터인 `result` 객체는 `resultCode`(RESULT_OK, `RESULT_CANCELED`)와 `data`(Intent)를 포함하고 있어, 이를 통해 결과를 손쉽게 처리할 수 있습니다.

이 방식은 RequestCode를 직접 관리할 필요가 없고, 코드가 콜백 기반으로 명확하게 분리되며, 액티비티가 재생성되어도 결과 콜백을 안전하게 수신할 수 있다는 큰 장점이 있습니다.


고급 팁과 활용 사례

기본적인 구현을 마스터했다면, 이제 몇 가지 고급 기술을 더해 이 기법을 한 단계 더 발전시켜 봅시다.

1. 다이얼로그 크기 동적 조절

기본적으로 다이얼로그의 크기는 레이아웃의 `wrap_content`에 의해 결정됩니다. 하지만 때로는 화면 너비의 특정 비율(예: 90%)을 차지하도록 만들고 싶을 수 있습니다. 다이얼로그 액티비티의 onCreate() 메서드 마지막에 다음 코드를 추가하여 크기를 프로그래밍 방식으로 조절할 수 있습니다.

// in CustomDialogActivity.kt, at the end of onCreate()
override fun onCreate(savedInstanceState: Bundle?) {
    // ... 기존 코드 ...

    // 화면 너비의 90%로 다이얼로그 크기 설정
    val displayMetrics = resources.displayMetrics
    val width = (displayMetrics.widthPixels * 0.9).toInt()
    window.setLayout(width, ViewGroup.LayoutParams.WRAP_CONTENT)
}

이 코드는 현재 기기의 화면 너비를 가져와 그 90%를 다이얼로그의 너비로 설정합니다. 높이는 기존처럼 내용물에 맞게 유지됩니다.

2. 커스텀 등장/사라짐 애니메이션 적용

다이얼로그가 좀 더 부드럽게 나타나고 사라지게 하려면 애니메이션을 적용할 수 있습니다. 먼저 `res/anim` 폴더에 애니메이션 XML 파일을 만듭니다. (예: `slide_up.xml`, `slide_down.xml`)

그 다음, `styles.xml`에 정의했던 다이얼로그 테마를 수정하여 애니메이션 스타일을 추가합니다.


<style name="Theme.MyApp.Dialog" parent="Theme.AppCompat.Dialog">
    
    <item name="android:windowAnimationStyle">@style/DialogAnimation</item>
</style>

<style name="DialogAnimation">
    <item name="android:windowEnterAnimation">@anim/slide_up</item>
    <item name="android:windowExitAnimation">@anim/slide_down</item>
</style>

이렇게 하면 `CustomDialogActivity`가 나타날 때는 `slide_up` 애니메이션이, 사라질 때는 `slide_down` 애니메이션이 자동으로 적용됩니다.

3. 언제 `DialogFragment` 대신 이 방법을 사용해야 할까?

모든 상황에 이 방법이 정답은 아닙니다. 두 기술의 장단점을 이해하고 상황에 맞게 선택하는 것이 중요합니다.

  • 'Activity-as-Dialog'가 더 나은 경우:
    • 다이얼로그가 자체적인 툴바, 메뉴 등을 가져야 할 때
    • 다이얼로그 내부 로직이 매우 복잡하여 별도의 생명주기를 갖는 것이 유리할 때 (예: 네트워크 요청, 데이터베이스 접근 등)
    • 다이얼로그 내부에 `RecyclerView`, `ViewPager2`, `Fragment` 등 복잡한 뷰가 포함될 때
    • 다이얼로그 자체가 또 다른 액티비티를 호출해야 하는 경우
    • 앱의 여러 위치에서 동일한 다이얼로그 UI/로직을 재사용해야 할 때
  • `DialogFragment`가 더 나은 경우:
    • 간단한 확인/취소, 알림, 텍스트 입력 등 단순한 상호작용만 필요할 때
    • 다이얼로그가 호스트 `Fragment`나 `Activity`의 ViewModel을 직접 공유하는 등 매우 긴밀하게 결합되어야 할 때
    • 구글이 제공하는 표준 다이얼로그(DatePickerDialog, TimePickerDialog 등)를 약간 커스텀하여 사용할 때
    • 단순히 모달 UI를 띄우는 것이 주 목적일 때

결론

우리는 Activity에 다이얼로그 테마를 적용하는 것만으로 얼마나 강력하고 유연한 커스텀 다이얼로그를 만들 수 있는지 확인했습니다. 이 방법은 복잡한 다이얼로그 구현에서 발생하는 많은 문제, 특히 생명주기 관리와 데이터 통신의 어려움을 우회할 수 있는 우아한 해결책입니다. 일반적인 `Activity` 개발 방식의 연장선에 있기 때문에 배우기 쉽고, 한번 익혀두면 두고두고 유용하게 사용할 수 있는 강력한 무기가 될 것입니다.

이제 여러분의 프로젝트에서 복잡한 `DialogFragment` 코드를 마주하게 된다면, 잠시 멈추고 'Activity-as-Dialog' 기법을 떠올려보세요. 더 깔끔하고, 더 직관적이며, 더 관리하기 쉬운 코드로 멋진 다이얼로그 경험을 사용자에게 선사할 수 있을 것입니다.


0 개의 댓글:

Post a Comment