Tuesday, March 22, 2022

안드로이드 EditText 스트레스 끝: 화면 터치로 키보드 숨기는 가장 확실한 방법 (Kotlin/Java/Compose)

안드로이드 앱을 개발하다 보면 사용자의 입력을 받기 위해 EditText(또는 Jetpack Compose의 TextField)를 사용하는 것은 필수적입니다. 사용자가 EditText를 터치하면 키보드가 자연스럽게 올라오고, 입력을 마친 후에는 키보드가 사라져야 앱의 나머지 콘텐츠를 볼 수 있습니다. 하지만 안드로이드 시스템은 이 "입력이 끝난 시점"을 명확하게 알지 못합니다. 그 결과, 사용자는 입력을 마친 후에도 여전히 화면의 절반을 차지하고 있는 키보드 때문에 불편을 겪는 경우가 많습니다.

가장 흔한 시나리오는 다음과 같습니다.

  • 검색 기능: 사용자가 검색창에 키워드를 입력한 후, 키보드 아래에 가려진 검색 결과를 보기 위해 불필요한 행동을 해야 합니다.
  • 로그인/회원가입: 아이디와 비밀번호를 모두 입력했지만, '로그인' 버튼을 누르기 전에 키보드를 직접 내려야 하는 경우가 있습니다.
  • 메신저 앱: 메시지를 입력하고 전송한 후, 이전 대화 내용을 확인하고 싶은데 키보드가 화면을 가리고 있습니다.

이러한 사용자 경험(UX) 저하를 막기 위해 개발자는 키보드를 적절한 시점에 숨겨주는 코드를 직접 구현해야 합니다. 가장 직관적이고 사용자 친화적인 방법은 '입력창(EditText) 외부의 화면을 터치했을 때 키보드를 자동으로 내리는 것'입니다. 이 방식은 사용자가 "이제 입력이 끝났으니 다른 것을 보겠다"는 의도를 가장 자연스럽게 반영합니다.

본문에서는 이 핵심적인 기능을 구현하는 다양한 방법을 깊이 있게 탐구합니다. 전통적인 View 시스템(XML 레이아웃) 환경에서의 접근법부터 최신 Jetpack Compose 환경에서의 해결책까지, 각 방법의 원리와 장단점, 그리고 실제 프로젝트에 바로 적용할 수 있는 상세한 코드 예제를 Kotlin과 Java 언어 모두로 제공합니다. 단순히 코드를 복사-붙여넣기 하는 것을 넘어, 그 코드가 왜 그렇게 동작하는지 근본적인 원리를 이해하여 어떤 상황에서도 능숙하게 대처할 수 있도록 돕는 것이 이 글의 목표입니다.

안드로이드에서 화면 터치로 키보드를 내리는 동작 예시
EditText 외부를 터치했을 때 키보드가 사라지는 직관적인 사용자 경험

1. 문제의 핵심: 키보드와 포커스(Focus)의 관계 이해하기

키보드를 제어하는 방법을 배우기 전에, 안드로이드에서 키보드가 언제 나타나고 사라지는지에 대한 근본적인 메커니즘을 이해해야 합니다. 키보드의 동작은 전적으로 '포커스(Focus)'와 연결되어 있습니다.

  • 포커스 획득: 사용자가 EditText와 같이 입력을 받을 수 있는 View를 터치하면, 해당 View는 '포커스'를 획득합니다. 안드로이드 시스템은 이 포커스 획득 이벤트를 감지하고, 해당 View가 텍스트 입력을 위한 것임을 인지하여 자동으로 소프트 키보드를 화면에 표시합니다.
  • 포커스 유지: 한번 포커스를 받은 EditText는 사용자가 다른 입력 가능한 View를 터치하거나, 개발자가 코드를 통해 명시적으로 포커스를 제거하지 않는 한 포커스를 계속 유지합니다. 이것이 바로 키보드가 사라지지 않는 주된 이유입니다.
  • 포커스 상실: 키보드를 숨기려면, 현재 포커스를 가진 EditText로부터 포커스를 빼앗아야 합니다. clearFocus() 메소드를 호출하여 포커스를 제거하면, 시스템은 더 이상 입력을 받을 대상이 없다고 판단하고 키보드를 숨기려는 시도를 합니다.

결론적으로, 우리의 목표는 "EditText가 아닌 다른 곳을 터치했을 때, 현재 포커스를 가진 EditText의 포커스를 프로그래밍적으로 제거하고, 이어서 키보드를 숨기라는 명령을 시스템에 전달하는 것"으로 요약할 수 있습니다.

2. [View 시스템] 베이스 액티비티(BaseActivity)를 활용한 전역적 접근법

가장 고전적이면서도 강력한 방법 중 하나는 모든 액티비티의 부모가 될 BaseActivity를 만들고, 이 곳에 화면 터치 이벤트를 가로채는 로직을 구현하는 것입니다. 이 방법을 사용하면, 앱 내의 모든 화면에 일관된 키보드 숨김 동작을 별도의 추가 코드 없이 적용할 수 있습니다.

이 방법의 핵심은 액티비티의 dispatchTouchEvent 메소드를 오버라이드하는 것입니다.

`dispatchTouchEvent(MotionEvent ev)`란?
이 메소드는 액티비티에서 발생하는 모든 터치 이벤트(손가락을 대는 순간, 움직이는 동안, 떼는 순간 등)가 하위 View들에게 전달되기 전에 가장 먼저 호출되는 관문입니다. 여기에 코드를 작성하면, 특정 버튼이나 리스트 아이템이 터치 이벤트를 받기 전에 우리가 원하는 동작(예: 키보드 숨기기)을 먼저 수행할 수 있습니다.

2.1. 구현 코드 (Kotlin & Java)

먼저, 모든 액티비티가 상속받을 추상 클래스 BaseActivity를 생성합니다.

Kotlin 코드 예제 (`BaseActivity.kt`)


import android.content.Context
import android.graphics.Rect
import android.view.MotionEvent
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import androidx.appcompat.app.AppCompatActivity

abstract class BaseActivity : AppCompatActivity() {

    /**
     * 화면 터치 시 키보드 숨기기 로직
     * @param ev MotionEvent
     * @return super.dispatchTouchEvent(ev)
     */
    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
        // 이벤트가 null이 아니고, 사용자가 화면을 터치했을 때(ACTION_DOWN)만 로직을 실행합니다.
        if (ev?.action == MotionEvent.ACTION_DOWN) {
            // 현재 포커스를 받고 있는 View를 가져옵니다.
            val currentFocusedView = currentFocus
            if (currentFocusedView is EditText) {
                val outRect = Rect()
                // EditText의 영역을 사각형(Rect)으로 가져옵니다.
                currentFocusedView.getGlobalVisibleRect(outRect)

                // 사용자가 터치한 좌표가 EditText의 영역 밖인지 확인합니다.
                if (!outRect.contains(ev.rawX.toInt(), ev.rawY.toInt())) {
                    // EditText의 포커스를 해제합니다.
                    currentFocusedView.clearFocus()
                    
                    // InputMethodManager를 통해 키보드를 숨깁니다.
                    val imm: InputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
                    imm.hideSoftInputFromWindow(currentFocusedView.windowToken, 0)
                }
            }
        }
        return super.dispatchTouchEvent(ev)
    }
}

Java 코드 예제 (`BaseActivity.java`)


import android.content.Context;
import android.graphics.Rect;
import android.view.MotionEvent;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import androidx.appcompat.app.AppCompatActivity;

public abstract class BaseActivity extends AppCompatActivity {

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        // 이벤트가 null이 아니고, 사용자가 화면을 터치했을 때(ACTION_DOWN)만 로직을 실행합니다.
        if (ev.getAction() == MotionEvent.ACTION_DOWN) {
            // 현재 포커스를 받고 있는 View를 가져옵니다.
            View currentFocusedView = getCurrentFocus();
            if (currentFocusedView instanceof EditText) {
                Rect outRect = new Rect();
                // EditText의 영역을 사각형(Rect)으로 가져옵니다.
                currentFocusedView.getGlobalVisibleRect(outRect);

                // 사용자가 터치한 좌표가 EditText의 영역 밖인지 확인합니다.
                if (!outRect.contains((int) ev.getRawX(), (int) ev.getRawY())) {
                    // EditText의 포커스를 해제합니다.
                    currentFocusedView.clearFocus();

                    // InputMethodManager를 통해 키보드를 숨깁니다.
                    InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
                    imm.hideSoftInputFromWindow(currentFocusedView.getWindowToken(), 0);
                }
            }
        }
        return super.dispatchTouchEvent(ev);
    }
}

2.2. 코드 상세 분석

  • if (ev?.action == MotionEvent.ACTION_DOWN): 터치 이벤트는 여러 종류가 있습니다(누름, 뗌, 움직임 등). 우리는 사용자가 화면을 '처음 눌렀을 때' 한 번만 이 로직을 실행하면 되므로 ACTION_DOWN 이벤트를 확인합니다. 불필요한 반복 실행을 막아 성능을 최적화합니다.
  • val currentFocusedView = currentFocus: 액티비티의 currentFocus 프로퍼티(또는 getCurrentFocus() 메소드)는 현재 화면에서 포커스를 가지고 있는 View 객체를 반환합니다. 만약 아무것도 포커스를 갖고 있지 않다면 null을 반환합니다.
  • if (currentFocusedView is EditText): 이 로직은 오직 EditText가 포커스를 가지고 있을 때만 의미가 있습니다. 다른 종류의 View(예: 버튼)가 포커스를 가졌을 때는 키보드가 올라와 있지 않으므로 아무런 처리도 하지 않습니다.
  • currentFocusedView.getGlobalVisibleRect(outRect): 이 메소드는 View의 화면상 절대 좌표를 Rect 객체에 담아줍니다. Rect는 왼쪽(left), 위(top), 오른쪽(right), 아래(bottom) 좌표를 가지고 있는 사각형 클래스입니다. 즉, 현재 포커스된 EditText가 화면 어디에 위치하는지 그 영역 정보를 가져옵니다.
  • !outRect.contains(ev.rawX.toInt(), ev.rawY.toInt()): 여기가 핵심 로직입니다. Rect.contains(x, y)는 주어진 (x, y) 좌표가 사각형 영역 내부에 있는지 boolean 값으로 반환합니다. ev.rawXev.rawY는 사용자가 터치한 지점의 화면 전체 기준 절대 좌표입니다. 따라서 이 코드는 "사용자가 터치한 지점이 EditText 영역 밖인가?"를 검사하는 것입니다.
  • currentFocusedView.clearFocus(): 터치한 곳이 EditText 밖이라고 확인되면, 가장 먼저 해당 EditText의 포커스를 제거합니다.
  • imm.hideSoftInputFromWindow(...): InputMethodManager(IMM)는 안드로이드의 입력 방식(키보드 등)을 관리하는 시스템 서비스입니다. hideSoftInputFromWindow 메소드는 키보드를 숨기는 역할을 합니다.
    • 첫 번째 파라미터 `currentFocusedView.windowToken`: 키보드는 특정 View에 종속된 것이 아니라, 그 View가 속한 '윈도우(Window)'에 연결되어 있습니다. windowToken은 이 윈도우를 고유하게 식별하는 '열쇠'와 같습니다. 시스템에 "이 열쇠에 해당하는 윈도우에 연결된 키보드를 숨겨줘"라고 요청하는 것입니다.
    • 두 번째 파라미터 `0`: 키보드를 숨길 때 사용할 추가적인 플래그입니다. 특별한 경우가 아니라면 보통 0 (또는 InputMethodManager.HIDE_NOT_ALWAYS)을 사용합니다.
  • return super.dispatchTouchEvent(ev): 우리의 로직을 수행한 후, 원래 시스템이 하려던 일(터치 이벤트를 실제 View에 전달하는 등)을 계속하도록 super 메소드를 호출해주는 것이 매우 중요합니다. 이를 호출하지 않으면 버튼 클릭 등 앱의 모든 터치 동작이 먹통이 됩니다.

2.3. 사용 방법 및 장단점

사용 방법:

이제 앱의 모든 액티비티가 AppCompatActivity 대신 우리가 만든 BaseActivity를 상속받도록 수정하기만 하면 됩니다.


// 기존: class MainActivity : AppCompatActivity()
// 변경:
class MainActivity : BaseActivity() {
    // ...
}

// 기존: class ProfileActivity : AppCompatActivity()
// 변경:
class ProfileActivity : BaseActivity() {
    // ...
}

이렇게 하면 MainActivityProfileActivity 모두 별도의 코드 없이 화면 외부 터치 시 키보드가 자동으로 숨겨지는 기능이 적용됩니다.

장점:

  • DRY(Don't Repeat Yourself) 원칙: 코드를 한 곳(BaseActivity)에만 작성하므로 중복이 없고 유지보수가 편리합니다.
  • 전역 적용: 새로운 액티비티를 추가할 때 BaseActivity를 상속받기만 하면 되므로 실수를 줄이고 일관성을 유지할 수 있습니다.

단점:

  • 과도한 일반화: 어떤 화면에서는 이 기능이 방해가 될 수 있습니다. 예를 들어, 여러 EditText가 있는 복잡한 폼(form) 화면에서 사용자가 한 입력창에서 다른 입력창으로 바로 넘어가고 싶을 때, 그 사이의 여백을 실수로 터치하면 키보드가 사라져 다시 올려야 하는 불편함이 생길 수 있습니다.
  • 유연성 부족: 특정 액티비티에서만 이 동작을 제외하고 싶을 때, 추가적인 플래그나 로직을 BaseActivity에 구현해야 해서 코드가 복잡해질 수 있습니다.
  • 잠재적 충돌: 만약 지도(Map)나 복잡한 커스텀 드로잉 View처럼 자체적으로 정교한 터치 이벤트를 처리하는 View가 있다면 dispatchTouchEvent를 오버라이드하는 것이 예상치 못한 동작을 유발할 수 있습니다.

3. [View 시스템] 유틸리티(Utility) 함수와 리스너를 이용한 선택적 접근법

BaseActivity 방식이 너무 광범위하다고 느껴진다면, 특정 화면에만 이 기능을 선택적으로 적용하는 것이 더 나은 대안이 될 수 있습니다. 이 방법은 재사용 가능한 '키보드 숨기기' 유틸리티 함수를 만들고, 필요한 화면의 최상위 레이아웃에 터치 리스너를 설정하여 이 함수를 호출하는 방식입니다.

이 접근법은 유연성이 높고, 다른 코드에 미치는 영향을 최소화할 수 있다는 장점이 있습니다.

3.1. 구현 코드 (Kotlin & Java)

먼저, 어디서든 호출할 수 있는 키보드 숨기기 유틸리티 함수를 만듭니다.

Kotlin 코드 예제 (`KeyboardUtils.kt`)

Kotlin에서는 파일 최상단에 함수를 선언하여 쉽게 유틸리티 함수를 만들 수 있습니다.


import android.app.Activity
import android.content.Context
import android.view.View
import android.view.inputmethod.InputMethodManager

/**
 * 현재 포커스된 View로부터 키보드를 숨기는 함수
 * @param activity 키보드를 숨기려는 컨텍스트를 가진 액티비티
 */
fun hideKeyboard(activity: Activity) {
    val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
    // 현재 포커스를 가진 View를 찾습니다. 없으면 새로 View를 생성하여 focus를 줍니다.
    var view = activity.currentFocus
    if (view == null) {
        view = View(activity)
    }
    // 포커스를 가진 View의 windowToken을 이용해 키보드를 숨깁니다.
    imm.hideSoftInputFromWindow(view.windowToken, 0)
}

Java 코드 예제 (`KeyboardUtils.java`)

Java에서는 static 메소드를 가진 클래스를 만듭니다.


import android.app.Activity;
import android.content.Context;
import android.view.View;
import android.view.inputmethod.InputMethodManager;

public class KeyboardUtils {
    /**
     * 현재 포커스된 View로부터 키보드를 숨기는 정적 메소드
     * @param activity 키보드를 숨기려는 컨텍스트를 가진 액티비티
     */
    public static void hideKeyboard(Activity activity) {
        InputMethodManager imm = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
        // 현재 포커스를 가진 View를 찾습니다. 없으면 새로 View를 생성하여 focus를 줍니다.
        View view = activity.getCurrentFocus();
        if (view == null) {
            view = new View(activity);
        }
        // 포커스를 가진 View의 windowToken을 이용해 키보드를 숨깁니다.
        imm.hideSoftInputFromWindow(view.getWindowToken(), 0);
    }
}

이제 이 유틸리티 함수를 사용할 액티비티 또는 프래그먼트에서, 최상위 레이아웃에 터치 리스너를 설정해 주기만 하면 됩니다.

액티비티에 적용하기 (`MainActivity.kt`)

먼저 레이아웃 XML 파일에서 최상위 레이아웃에 ID를 부여합니다.


<!-- activity_main.xml -->
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/root_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <EditText
        android:id="@+id/my_edit_text"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        ... />
    
    <!-- 다른 View들 -->

</androidx.constraintlayout.widget.ConstraintLayout>

그 다음, `onCreate` 메소드에서 리스너를 설정합니다.


import android.os.Bundle
import android.view.MotionEvent
import androidx.appcompat.app.AppCompatActivity
import com.yourpackage.databinding.ActivityMainBinding // ViewBinding 사용

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

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

        // 최상위 레이아웃에 터치 리스너 설정
        binding.rootLayout.setOnTouchListener { view, motionEvent ->
            // 터치 이벤트가 발생하면 키보드를 숨깁니다.
            hideKeyboard(this)
            
            // EditText의 포커스를 명시적으로 해제합니다.
            currentFocus?.clearFocus()

            // 이벤트를 소비하지 않고 계속 전파되도록 false를 반환할 수 있으나,
            // 보통 배경 터치 시 다른 동작이 필요 없으므로 true로 이벤트를 소비하는 것이 깔끔할 수 있습니다.
            // 하지만 자식 View의 클릭 이벤트 등을 방해하지 않으려면 false를 반환하는 것이 더 안전합니다.
            // 여기서는 다른 동작이 없다고 가정하고 false를 반환합니다.
            false
        }
    }
}

위 코드에서 hideKeyboard(this)를 호출하면, 현재 포커스된 뷰가 무엇이든 상관없이 키보드를 숨깁니다. currentFocus?.clearFocus()를 추가로 호출하여 포커스까지 확실하게 제거해주는 것이 좋습니다. 리스너가 false를 반환하면 터치 이벤트가 하위 뷰로 계속 전달될 수 있어, 예를 들어 배경에 있는 버튼이 여전히 클릭될 수 있습니다. true를 반환하면 여기서 이벤트가 '소비'되어 더 이상 전파되지 않습니다.

3.2. 장단점

장점:

  • 높은 유연성 및 제어: 이 기능이 필요한 화면에만 선택적으로 적용할 수 있습니다.
  • 낮은 결합도: BaseActivity처럼 상속 구조에 의존하지 않으므로 코드 구조가 더 유연해집니다. 프래그먼트에도 쉽게 적용할 수 있습니다.
  • 안전성: 전역적인 dispatchTouchEvent를 건드리지 않으므로, 복잡한 터치 로직을 가진 다른 컴포넌트와 충돌할 가능성이 거의 없습니다.

단점:

  • 반복적인 코드: 이 기능이 필요한 모든 액티비티/프래그먼트마다 리스너를 설정하는 코드를 추가해야 합니다. (물론 이 부분도 확장 함수 등으로 개선할 수 있습니다.)
  • 실수할 가능성: 개발자가 새 화면을 만들 때 리스너 설정을 잊어버리면 해당 화면에서는 기능이 동작하지 않아 일관성이 깨질 수 있습니다.

4. [Jetpack Compose] 선언형 UI를 위한 현대적인 접근법

Jetpack Compose는 안드로이드 UI 개발의 패러다임을 바꿨습니다. View와 XML이 없는 선언형 UI 환경에서는 키보드 제어 방식 또한 다릅니다. Compose에서는 LocalSoftwareKeyboardControllerLocalFocusManager를 사용하여 훨씬 더 직관적이고 깔끔하게 동일한 기능을 구현할 수 있습니다.

4.1. 구현 코드 (Kotlin & Jetpack Compose)

Compose에서의 핵심 아이디어는 화면의 최상단 Composable에 pointerInput Modifier를 추가하여 탭(tap) 제스처를 감지하고, 감지되었을 때 포커스 매니저와 키보드 컨트롤러를 이용해 포커스를 해제하고 키보드를 숨기는 것입니다.


import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable
fun KeyboardHidingScreen() {
    // 1. 키보드 컨트롤러와 포커스 매니저를 가져옵니다.
    val keyboardController = LocalSoftwareKeyboardController.current
    val focusManager = LocalFocusManager.current

    // Scaffold나 Box와 같은 최상위 Composable에 pointerInput Modifier를 적용합니다.
    Scaffold(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                // 탭 제스처를 감지합니다.
                detectTapGestures(
                    onTap = {
                        // 2. 탭 이벤트가 발생하면 키보드를 숨기고 포커스를 해제합니다.
                        keyboardController?.hide()
                        focusManager.clearFocus()
                    }
                )
            }
    ) { innerPadding ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding)
                .padding(16.dp),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            var text1 by remember { mutableStateOf("") }
            var text2 by remember { mutableStateOf("") }

            Text("아래 입력창에 텍스트를 입력해보세요.")
            Spacer(modifier = Modifier.height(16.dp))

            OutlinedTextField(
                value = text1,
                onValueChange = { text1 = it },
                label = { Text("사용자 이름") }
            )

            Spacer(modifier = Modifier.height(8.dp))

            OutlinedTextField(
                value = text2,
                onValueChange = { text2 = it },
                label = { Text("비밀번호") }
            )
        }
    }
}

@Preview(showBackground = true)
@Composable
fun KeyboardHidingScreenPreview() {
    KeyboardHidingScreen()
}

4.2. 코드 상세 분석

  • @OptIn(ExperimentalComposeUiApi::class): LocalSoftwareKeyboardController가 아직 실험적인 API이므로, 이 어노테이션을 추가하여 사용함을 명시해야 합니다.
  • LocalSoftwareKeyboardController.current: Composition 로컬을 통해 현재 컴포지션 트리에서 사용 가능한 SoftwareKeyboardController의 인스턴스를 가져옵니다. 이 컨트롤러에는 show()hide() 메소드가 있어 키보드를 직접 제어할 수 있습니다.
  • LocalFocusManager.current: 마찬가지로 현재 컴포지션에서 사용 가능한 FocusManager를 가져옵니다. 포커스 이동(moveFocus)이나 해제(clearFocus)를 담당합니다.
  • Modifier.pointerInput(Unit) { ... }: 이 Modifier는 Composable에 복잡한 포인터(터치, 마우스 등) 이벤트를 처리하는 로직을 추가할 때 사용됩니다. key1 = Unit은 이 블록이 리컴포지션을 유발하지 않도록 하는 일반적인 패턴입니다.
  • detectTapGestures(onTap = { ... }): pointerInput 스코프 내에서 사용할 수 있는 제스처 감지 함수입니다. 길게 누르기(onLongPress), 두 번 탭(onDoubleTap) 등 다양한 제스처를 감지할 수 있지만, 여기서는 간단한 탭(onTap)만 필요합니다.
  • keyboardController?.hide()focusManager.clearFocus(): 탭이 감지되면, 가져왔던 컨트롤러와 매니저를 사용하여 키보드를 숨기고 모든 포커스를 해제합니다. 이 두 가지를 함께 호출하는 것이 가장 확실한 방법입니다.

4.3. 장점 및 활용

장점:

  • 선언적이고 직관적: "이 UI 영역은 탭을 감지하여 키보드를 숨긴다"는 로직이 코드에 명확하게 드러납니다.
  • 스코프 제어 용이: Modifier를 적용하는 Composable의 범위를 조절하여 키보드 숨김 동작이 적용될 영역을 쉽게 제어할 수 있습니다. 화면 전체에 적용할 수도, 특정 카드(Card) 내부에만 적용할 수도 있습니다.
  • 재사용성: 이 로직을 커스텀 Modifier로 추출하여 재사용성을 극대화할 수 있습니다.

재사용 가능한 Modifier로 만들기

더 깔끔한 코드를 위해 이 로직을 확장 함수 형태의 커스텀 Modifier로 만들 수 있습니다.


@OptIn(ExperimentalComposeUiApi::class)
fun Modifier.clearFocusOnTap(): Modifier = this.pointerInput(Unit) {
    detectTapGestures(onTap = {
        // 이 Modifier가 적용된 Composable의 context를 사용하기 위해
        // LocalSoftwareKeyboardController와 LocalFocusManager를 여기서 선언하면 안됩니다.
        // 대신, 사용하는 쪽에서 미리 선언된 컨트롤러를 사용해야 합니다.
        // 따라서 이 방법보다는 Composable 함수로 래핑하는 것이 더 안전합니다.
    })
}

// 더 나은 접근법: Composable 래퍼
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun HidableKeyboardBox(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    val keyboardController = LocalSoftwareKeyboardController.current
    val focusManager = LocalFocusManager.current

    Box(
        modifier = modifier.pointerInput(Unit) {
            detectTapGestures(onTap = {
                keyboardController?.hide()
                focusManager.clearFocus()
            })
        }
    ) {
        content()
    }
}

// 사용 예:
// HidableKeyboardBox(modifier = Modifier.fillMaxSize()) {
//     // 내 화면 콘텐츠 Composable
// }

HidableKeyboardBox와 같은 래퍼 Composable을 만들어두면, 키보드 숨김 기능이 필요한 모든 화면에서 최상위를 이 Composable로 감싸주기만 하면 되므로 매우 편리하고 일관된 코드를 작성할 수 있습니다.


5. 결론 및 최선의 선택 가이드

지금까지 안드로이드에서 EditText(또는 TextField) 외부 화면을 터치했을 때 키보드를 숨기는 세 가지 주요 방법을 상세히 살펴보았습니다. 각 방법은 고유한 장단점을 가지고 있으므로, 현재 개발 중인 프로젝트의 특성과 요구사항에 맞춰 최적의 방법을 선택하는 것이 중요합니다.

방법 핵심 기술 추천 상황 주의할 점
BaseActivity 오버라이드 dispatchTouchEvent 앱의 모든 화면에 일관된 전역 정책을 적용하고 싶을 때. 단순한 구조의 앱. 유연성이 부족하고, 복잡한 터치 로직과 충돌할 수 있음. 예외 처리가 필요할 경우 코드가 복잡해짐.
유틸리티 함수 + 리스너 OnTouchListener, 최상위 뷰 특정 화면이나 프래그먼트에만 선택적으로 기능을 적용하고 싶을 때. 가장 유연하고 안전한 View 시스템 접근법. 적용이 필요한 모든 곳에 코드를 추가해야 하는 번거로움. 개발자가 누락할 가능성 존재.
Jetpack Compose Modifier pointerInput, LocalFocusManager Jetpack Compose로 UI를 개발하는 모든 프로젝트. 가장 현대적이고 선언적인 방법. View 시스템 기반의 프로젝트에는 적용할 수 없음.

최종 권장 사항은 다음과 같습니다.

  • 전통적인 View 시스템으로 개발 중이라면, BaseActivity의 유혹을 뿌리치고 유틸리티 함수와 리스너를 사용하는 방법(3번)을 우선적으로 고려하세요. 이 방법은 프로젝트가 복잡해지더라도 유지보수성과 유연성을 해치지 않는 가장 균형 잡힌 선택입니다.
  • Jetpack Compose로 새 프로젝트를 시작하거나 마이그레이션 중이라면, 주저 없이 pointerInput Modifier를 활용하는 방법(4번)을 채택하세요. 이는 Compose의 패러다임에 가장 잘 부합하며, 깔끔하고 재사용 가능한 코드를 작성할 수 있게 해줍니다.

결국, 사용자가 앱을 사용하는 동안 키보드로 인해 겪는 작은 불편함들을 세심하게 해결해주는 것이 뛰어난 사용자 경험의 시작입니다. 오늘 다룬 내용을 통해 여러분의 앱이 한 단계 더 부드럽고 직관적으로 동작하기를 바랍니다.


0 개의 댓글:

Post a Comment