본 글에서는 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>
이번 글에서는 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개의 함수를 재정하고 있습니다.
이 3개의 함수만 있으면 원하는 형태로 노출할 수 있습니다.
먼저 개선한 영상을 보겠습니다.
<LinearLayout ...>
<com.pluu.view.edittext.EditTextWithContainer .../>
<Button ... />
</LinearLayout>
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)
}
}
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
Subscribe to this blog via RSS.
LazyColumn/Row에서 동일한 Key를 사용하면 크래시가 발생하는 이유
Posted on 30 Nov 2024