EditText 포커스 레벨업

EditText 포커스 레벨업

Mar 4, 2023. | By: pluulove

본 글에서는 EditText 입력 시 포커스와 키보드 표시를 더 자연스럽게 다루는 방법을 소개합니다.

🚧🚧🚧 포스팅에 사용된 내용은 해당 동작만 체크했습니다. 이외의 내용은 다루지 않습니다. 🚧🚧🚧


포스팅에 사용한 샘플 코드 : https://github.com/Pluu/EditTextWithContainerSample


앱 입력 화면에서는 EditText뿐만 아니라 관련 설명과 입력 길이 및 버튼 등 다른 View와 함께 안내하는 입력 폼 형태의 UI가 자주 사용됩니다. 그리고 이런 입력들은 EditText에만 맞춰서 노출되기보다는 그룹 형태로 노출되기를 원합니다.

기존 입력 방식의 단점

처음 보여준 입력 폼처럼 ViewGroup 내부에 EditText와 다른 View를 위치한 경우, EditText 클릭 시에는 EditText에 딱 맞춰서 화면 이동과 키보드가 노출됩니다. 입력한 문자열 길이를 노출하는 경우에 사용자가 몇 글자 입력되었는지 알려주지 않는다면 좋은 UX가 아닐 것입니다.

<LinearLayout ...>
  <androidx.constraintlayout.widget.ConstraintLayout ...>
    <TextView .../>
    <EditText .../>
    <TextView .../>
  </androidx.constraintlayout.widget.ConstraintLayout>
  <LinearLayout ...>
    <EditText ... />
    <Button .../>
  </LinearLayout>
</LinearLayout>

개선 시도하기

유사한 동작

입력 노출을 해결 방법으로 비슷한 UX가 Material Design에서 볼 수 있습니다. TextInputLayout 내부에 TextInputEditText를 배치하면, TextInputEditText 클릭 시 TextInputLayout 영역에 맞춰서 입력이 제어되고 있습니다.

Material Design ~ Text fields : https://m2.material.io/components/text-fields/android#using-text-fields

<LinearLayout ...>
  <com.google.android.material.textfield.TextInputLayout ...>
    <com.google.android.material.textfield.TextInputEditText .../>
  </com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

TextInputLayout에 OutlinedBox와 counter 적용한 후, TextInputEditText 선택 시 기대되는 영역(=TextInputLayout)만큼 이동/키보드 노출되고 있습니다.

추가로 TextInputLayout에 하단 여백으로 100dp를 준 경우에도 여백만큼 이동/키보드 노출하고 있습니다.

<LinearLayout ...>
  <!-- 하단 100dp 여백 준 경우 -->
  <com.google.android.material.textfield.TextInputLayout
    android:paddingBottom="100dp"
    ...>

    <com.google.android.material.textfield.TextInputEditText .../>
  </com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

TextInputEditText 내부의 숨은 기능

이번 글에서는 TextInputLayout의 동작보다는 TextInputEditText가 핵심입니다. TextInputEditText 내부 코드를 살펴보면 일부 동작에서 TextInputLayout를 가져온 후 별도 처리를 하고 있습니다.

public class TextInputEditText extends AppCompatEditText {
  @Nullable
  private TextInputLayout getTextInputLayout() {
    // 부모 뷰로 이동하면서 TextInputLayout를 찾음 
    ViewParent parent = getParent();
    while (parent instanceof View) {
      if (parent instanceof TextInputLayout) {
        return (TextInputLayout) parent;
      }
      parent = parent.getParent();
    }
    return null;
  }
  
  @Override
  public void getFocusedRect(@Nullable Rect r) {
    super.getFocusedRect(r);
    TextInputLayout textInputLayout = getTextInputLayout();
    if (shouldUseTextInputLayoutFocusedRect(textInputLayout) && r != null) {
      textInputLayout.getFocusedRect(parentRect);
      r.bottom = parentRect.bottom;
    }
  }

  @Override
  public boolean getGlobalVisibleRect(@Nullable Rect r, @Nullable Point globalOffset) {
    TextInputLayout textInputLayout = getTextInputLayout();
    return shouldUseTextInputLayoutFocusedRect(textInputLayout)
        ? textInputLayout.getGlobalVisibleRect(r, globalOffset)
        : super.getGlobalVisibleRect(r, globalOffset);
  }

  @Override
  public boolean requestRectangleOnScreen(@Nullable Rect rectangle) {
    TextInputLayout textInputLayout = getTextInputLayout();
    if (shouldUseTextInputLayoutFocusedRect(textInputLayout) && rectangle != null) {
      int bottomOffset = textInputLayout.getHeight() - getHeight();
      parentRect.set(
          rectangle.left,
          rectangle.top,
          rectangle.right,
          rectangle.bottom + bottomOffset);
      return super.requestRectangleOnScreen(parentRect);
    } else {
      return super.requestRectangleOnScreen(rectangle);
    }
  }
}

소스 출처 : https://github.com/material-components/material-components-android/blob/master/lib/java/com/google/android/material/textfield/TextInputEditText.java

대부분 흔하게 재정의하지 않지만, TextInputLayout와의 상호작용으로 3개의 함수를 재정하고 있습니다.

  • getFocusedRect : View의 Focused 상태에 따라 표시할 영역을 정의
  • getGlobalVisibleRect : 앱 화면 영역을 기준으로 View의 표시할 영역을 정의
  • requestRectangleOnScreen : View의 직사각형을 화면에 표시하도록 요청하고, 필요한 경우 필요한 만큼만 스크롤

이 3개의 함수만 있으면 원하는 형태로 노출할 수 있습니다.

개선 버전

먼저 개선한 영상을 보겠습니다.

<LinearLayout ...>
  <com.pluu.view.edittext.EditTextWithContainer .../>
  <Button ... />
</LinearLayout>

개선한 EditText 코드

TextInputEditText에서는 TextInputLayout가 필요했지만, 별도로 ViewGroup대신 기존 ViewGroup을 그대로 쓸 수 있도록 우회하는 방식을 채택했습니다. 그로 인해 포커스 영역을 지정할 별도 ViewGroup을 받을 수 있는 변수와 Setter를 추가했습니다.

class EditTextWithContainer @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = android.R.attr.editTextStyle
) : AppCompatEditText(context, attrs, defStyleAttr) {

    private val parentRect = Rect()
    // EditText 대신 별도로 포커스/키보드 노출에 사용할 Parent View
    private var parentView: WeakReference<View>? = null

    private fun getContainer(): View? = parentView?.get()
  
    ///////////////////////////////////////////////////////////////////////////
    // 아래 재정의한 함수는 TextInputEditText와 유사하게 코드 수정
    ///////////////////////////////////////////////////////////////////////////

    override fun getFocusedRect(r: Rect?) {
        super.getFocusedRect(r)
        val container = getContainer() ?: return
        if (r != null) {
            container.getFocusedRect(parentRect)
            r.bottom = parentRect.bottom
        }
    }

    override fun getGlobalVisibleRect(r: Rect?, globalOffset: Point?): Boolean {
        val container = getContainer()
        return container?.getGlobalVisibleRect(r, globalOffset)
            ?: super.getGlobalVisibleRect(r, globalOffset)
    }

    override fun requestRectangleOnScreen(rectangle: Rect?): Boolean {
        val container = getContainer()
        return if (container != null && rectangle != null) {
            val bottomOffset = container.height - height
            parentRect.set(
                rectangle.left,
                rectangle.top,
                rectangle.right,
                rectangle.bottom + bottomOffset
            )
            super.requestRectangleOnScreen(parentRect)
        } else {
            super.requestRectangleOnScreen(rectangle)
        }
    }

    fun setParentContainer(view: View) {
        // 외부로 주입된 View를 내부에 임시 저장하므로
        // 주입된 View가 변동으로 크래시 및 메모리 누수가 발생하지 않도록
        // 약한 참조로 View 처리
        this.parentView = WeakReference(view)
    }
}

Activity 코드

EditText에 포커스 용도로 처리할 별도 뷰를 주입만 하면 할 일은 끝났습니다. 실행 후 EditText 선택 시 원하는 입력 폼이 유지되면서 이동/키보드 노출되는 것을 확인할 수 있습니다.

class MainActivity : AppCompatActivity() {
  private lateinit var binding: ActivityMainBinding
  override fun onCreate(savedInstanceState: Bundle?) {
    ...
    binding.editTextWithContainer.setParentContainer(binding.container)
  }
}

정리

EditText를 자주 사용하지만, 입력 시 아쉬운 키보드와 UI 노출을 개선은 여전히 어려운 영역입니다. 본 글에서는 다루지 않은 접근성, 포커스 이동 등에 대해 테스트는 해보지 않았으므로 해당 부분에 대해서는 이해해주시길 바랍니다. 위 내용이 다른 분들에게도 도움이 되었으면 합니다.

comments powered by Disqus

Currnte Pages Tags

Android

About

Pluu, Android Developer Blog Site

이전 블로그 링크 :네이버 블로그

Using Theme : SOLID SOLID Github

Social Links