모바일 애플리케이션 개발에서 사용자로부터 텍스트를 입력받는 것은 가장 기본적이면서도 중요한 기능 중 하나입니다. Flutter에서는 TextField
위젯을 통해 이 기능을 손쉽게 구현할 수 있으며, 입력된 텍스트의 상태를 관리하고 제어하기 위해 TextEditingController
를 사용합니다. 대부분의 Flutter 개발자는 controller.text
를 통해 텍스트를 읽고 쓰는 기본적인 사용법에 익숙할 것입니다. 하지만 TextEditingController
의 잠재력은 단순히 텍스트를 저장하는 것을 넘어섭니다. 사용자의 입력 경험(UX)을 극대화하고, 복잡한 입력 시나리오를 우아하게 처리하기 위해서는 그 내부 동작 원리와 고급 활용법을 깊이 이해해야 합니다.
이 글에서는 TextEditingController
의 기본적인 사용법을 복습하는 것을 시작으로, 많은 개발자들이 실무에서 마주치는 '동적 텍스트 포맷팅 시 커서 위치 문제'를 심도 있게 파헤쳐 봅니다. 그리고 이 문제를 해결하는 과정에서 TextEditingController
의 핵심 구성 요소인 TextEditingValue
와 TextSelection
의 개념을 배우고, 이를 통해 어떻게 사용자의 입력 흐름을 방해하지 않으면서 자연스러운 인터페이스를 제공할 수 있는지 구체적인 코드를 통해 살펴보겠습니다. 마지막으로는 이러한 로직을 재사용 가능한 모듈로 만드는 'TextInputFormatter'까지 다루어, 여러분의 Flutter 개발 역량을 한 단계 끌어올리는 데 실질적인 도움을 드리고자 합니다.
1. 모든 것의 시작: TextEditingController 기본기 다지기
고급 기술을 논하기 전에, 기본을 탄탄히 다지는 것이 중요합니다. TextEditingController
는 Flutter의 상태 관리 철학을 잘 보여주는 예시입니다. 위젯 트리에서 상태(State)를 분리하여 UI 코드의 복잡도를 낮추고, 상태 변화에 유연하게 대응할 수 있도록 돕습니다.
1.1. 생성과 소멸: 생명주기 관리의 중요성
TextEditingController
는 상태를 가지는 객체이므로, 위젯의 생명주기에 맞춰 관리해주어야 합니다. 일반적으로 StatefulWidget
의 State
객체 내에서 초기화하고, 위젯이 화면에서 사라질 때(dispose) 반드시 컨트롤러의 리소스를 해제해주어야 합니다. 이를 지키지 않으면 메모리 누수(Memory Leak)의 원인이 될 수 있습니다.
import 'package:flutter/material.dart';
class BasicTextFieldScreen extends StatefulWidget {
const BasicTextFieldScreen({super.key});
@override
State<BasicTextFieldScreen> createState() => _BasicTextFieldScreenState();
}
class _BasicTextFieldScreenState extends State<BasicTextFieldScreen> {
// 1. State 객체 내에서 TextEditingController 인스턴스 생성
late final TextEditingController _controller;
@override
void initState() {
super.initState();
// 2. initState에서 컨트롤러 초기화. 초기 텍스트를 부여할 수 있음.
_controller = TextEditingController(text: '초기 텍스트');
}
@override
void dispose() {
// 3. dispose 메서드에서 반드시 controller를 dispose하여 리소스 해제
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('TextEditingController 기본'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
// 4. TextField의 controller 속성에 연결
controller: _controller,
decoration: const InputDecoration(
labelText: '텍스트를 입력하세요',
border: OutlineInputBorder(),
),
),
),
);
}
}
위 코드에서 볼 수 있듯이, initState
에서 컨트롤러를 생성하고, dispose
에서 해제하는 것이 정석적인 패턴입니다. late final
키워드를 사용하면 initState
에서 한번만 초기화됨을 명확히 할 수 있어 유용합니다.
1.2. 기본적인 텍스트 제어
컨트롤러가 TextField
에 연결되면, 우리는 이 컨트롤러를 통해 텍스트를 읽고, 변경하고, 지울 수 있습니다.
- 텍스트 읽기:
_controller.text
는 현재TextField
에 입력된 문자열을 반환합니다. - 텍스트 설정:
_controller.text = '새로운 텍스트';
와 같이 값을 할당하면TextField
의 내용이 해당 문자열로 즉시 변경됩니다. - 텍스트 지우기:
_controller.clear();
메서드를 호출하면 모든 텍스트가 지워집니다. 이는_controller.text = '';
와 동일한 효과를 가집니다.
// ... 위젯의 build 메서드 내부라고 가정 ...
Column(
children: [
TextField(
controller: _controller,
// ...
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
ElevatedButton(
onPressed: () {
// 현재 텍스트를 스낵바로 보여주기
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('현재 텍스트: ${_controller.text}')),
);
},
child: const Text('텍스트 읽기'),
),
ElevatedButton(
onPressed: () {
// 텍스트를 프로그래밍 방식으로 변경하기
_controller.text = 'Flutter는 대단해! ${DateTime.now()}';
},
child: const Text('텍스트 설정'),
),
ElevatedButton(
onPressed: () {
// 텍스트 전체 삭제
_controller.clear();
},
child: const Text('지우기'),
),
],
),
],
)
여기까지가 TextEditingController
의 가장 기본적인 사용법입니다. 하지만 이것만으로는 실시간으로 사용자 입력에 반응하는 동적인 UI를 만들기 어렵습니다.
2. 실시간 반응의 핵심: 리스너(Listener) 활용하기
TextEditingController
는 Flutter의 ChangeNotifier
를 상속받은 클래스입니다. 이는 컨트롤러의 내부 값이 변경될 때마다 "변경 알림"을 보낼 수 있다는 의미입니다. 우리는 addListener
메서드를 통해 이 알림을 구독하고, 텍스트가 변경될 때마다 특정 로직을 실행할 수 있습니다.
// ... _BasicTextFieldScreenState 클래스 내부 ...
@override
void initState() {
super.initState();
_controller = TextEditingController();
// 리스너 추가: 컨트롤러의 값이 변경될 때마다 호출될 함수를 등록
_controller.addListener(_printLatestValue);
}
@override
void dispose() {
// 리스너 제거: dispose 시점에 등록된 리스너를 제거해주어야 함
_controller.removeListener(_printLatestValue);
_controller.dispose();
super.dispose();
}
void _printLatestValue() {
// 텍스트가 변경될 때마다 콘솔에 현재 값을 출력
print('두 번째 화면 텍스트 필드: ${_controller.text}');
}
addListener
를 사용하면 사용자가 키보드로 한 글자 한 글자 입력하거나, 지우거나, 붙여넣기 하는 모든 변화를 감지할 수 있습니다. 또한, 위에서 본 것처럼 _controller.text = '...'
와 같이 코드로 텍스트를 변경할 때도 리스너는 동일하게 호출됩니다.
참고: `TextField`의 `onChanged` 콜백과의 차이점
TextField
위젯 자체에도 onChanged: (text) { ... }
라는 콜백이 있습니다. 이는 사용자가 직접 입력하여 텍스트가 변경될 때만 호출됩니다. 반면, controller.addListener
는 사용자의 입력뿐만 아니라 코드에 의한 텍스트 변경(controller.text = ...
)에도 반응한다는 차이가 있습니다. 따라서 어떤 종류의 변경에 반응해야 하는지에 따라 적절한 방법을 선택해야 합니다. 일반적으로 컨트롤러와 관련된 모든 변경에 반응하려면 addListener
가 더 강력한 선택지입니다.
3. 실전 문제: 동적 텍스트 포맷팅의 함정
이제 본격적으로 실무에서 마주할 법한 문제 상황으로 들어가 보겠습니다. 금융 앱이나 쇼핑몰 앱에서 흔히 볼 수 있는 '가격 입력 필드'를 만든다고 가정해 봅시다. 사용자가 '1000000'을 입력하면, 가독성을 위해 실시간으로 '1,000,000'처럼 3자리마다 쉼표(,)를 찍어주는 기능을 구현하고 싶습니다.
앞서 배운 addListener
를 사용하면 간단히 구현할 수 있을 것 같습니다. 순진한 첫 번째 접근법은 다음과 같을 것입니다.
3.1. 문제의 첫 번째 구현 (Naive Approach)
// ... State 클래스 내부 ...
final _priceController = TextEditingController();
@override
void initState() {
super.initState();
_priceController.addListener(_formatPrice);
}
void _formatPrice() {
// 현재 텍스트 가져오기
String text = _priceController.text;
// 쉼표 제거 (숫자만 남기기)
String plainNumber = text.replaceAll(',', '');
if (plainNumber.isEmpty) {
return;
}
// 숫자로 변환 후 포맷팅
try {
final number = int.parse(plainNumber);
// NumberFormat을 사용하기 위해 intl 패키지 추가 필요 (pubspec.yaml)
// dependencies:
// intl: ^0.18.0
final formatter = NumberFormat('#,###');
final formattedText = formatter.format(number);
// 포맷팅된 텍스트를 다시 컨트롤러에 설정
// **주의: 이 부분이 문제를 일으킵니다!**
if (_priceController.text != formattedText) {
_priceController.text = formattedText;
}
} catch (e) {
// 숫자로 파싱 실패 시 처리 (예: 사용자가 문자를 입력했을 때)
}
}
// ... dispose에서 리스너 제거 및 컨트롤러 dispose 처리 ...
위 코드를 실행하고 텍스트 필드에 '1234567'을 순서대로 입력해보면, 화면에는 '1,234,567'이 잘 표시됩니다. 성공한 것처럼 보입니다. 하지만 결정적인 문제가 있습니다.
3.2. 드러나는 문제점: 멋대로 움직이는 커서
숫자를 계속 입력해보면 이상한 점을 발견하게 됩니다. '123'을 입력하고 '4'를 입력하는 순간, 텍스트는 '1,234'로 바뀌지만 커서(입력 위치를 나타내는 깜빡이는 막대)가 맨 앞으로 이동해버립니다. 그래서 다음 숫자를 입력하면 '51,234'와 같이 원치 않는 결과가 나옵니다. 사용자는 매번 커서를 다시 맨 뒤로 옮겨야 하는 극심한 불편을 겪게 됩니다.
왜 이런 현상이 발생할까요?
원인은 _priceController.text = formattedText;
라인에 있습니다. controller.text
에 새로운 문자열을 할당하는 행위는, 단순히 텍스트만 교체하는 것이 아니라 사실상 컨트롤러의 전체 상태를 새로운 값으로 초기화하는 것과 같습니다. 이때, 커서의 위치(selection) 정보는 별도로 지정되지 않았기 때문에 기본값인 '맨 앞(offset: 0)'으로 리셋되어 버리는 것입니다.
이것은 사용자 경험에 치명적입니다. 사용자는 자신이 입력하던 흐름이 끊기고, 앱이 자신의 의도와 다르게 동작한다고 느끼게 됩니다. 이 문제를 해결하는 것이 바로 이 글의 핵심 목표입니다.
4. 문제 해결의 열쇠: `TextEditingValue`와 `TextSelection` 파헤치기
커서 문제를 해결하기 위해서는 TextEditingController
를 좀 더 깊이 들여다봐야 합니다. 사실 TextEditingController
는 String
이 아닌 TextEditingValue
라는 객체의 상태를 관리하는 ValueNotifier<TextEditingValue>
입니다. 우리가 controller.text
로 접근하는 것은 이 TextEditingValue
객체의 여러 속성 중 하나일 뿐입니다.
TextEditingValue
클래스의 구조를 살펴보면 실마리를 찾을 수 있습니다.
class TextEditingValue {
const TextEditingValue({
this.text = '',
this.selection = const TextSelection.collapsed(offset: -1),
this.composing = TextRange.empty,
});
// 현재 텍스트
final String text;
// 현재 커서 위치 또는 선택된 텍스트의 범위
final TextSelection selection;
// (주로 CJK 입력기 등에서) 현재 조합 중인 글자의 범위
final TextRange composing;
// ... 기타 메서드들 ...
}
보시다시피 TextEditingValue
는 텍스트(text
) 정보와 함께, 선택/커서 정보(selection
)를 가지고 있습니다. 바로 이 selection
속성이 우리가 제어해야 할 대상입니다.
TextSelection
클래스는 커서의 위치나 사용자가 드래그하여 선택한 텍스트의 범위를 나타냅니다. 몇 가지 중요한 생성자가 있습니다.
TextSelection.collapsed({required int offset})
: 텍스트 블록을 선택하지 않은, 단순히 깜빡이는 커서의 위치를 지정합니다.offset
은 0부터 시작하는 문자의 인덱스입니다. 예를 들어 'abc'에서 'c' 다음에 커서를 두려면offset: 3
이 됩니다.TextSelection.fromPosition(TextPosition position)
:TextPosition
객체를 받아 커서 위치를 지정합니다.TextSelection.collapsed
와 거의 동일한 역할을 합니다.TextSelection({required int baseOffset, required int extentOffset})
: 텍스트의 특정 범위를 선택할 때 사용합니다.baseOffset
은 선택이 시작된 위치,extentOffset
은 선택이 끝난 위치입니다.
이제 우리의 전략이 명확해졌습니다.
- 리스너 함수 안에서 텍스트를 포맷팅한다.
- 포맷팅 후, 커서가 위치해야 할 새로운 위치를 계산한다.
- 새로운 텍스트와 계산된 새 커서 위치를 담은 새로운 `TextEditingValue` 객체를 생성한다.
controller.text
대신controller.value
에 이 새로운 `TextEditingValue` 객체를 할당한다.
이렇게 하면 텍스트와 커서 위치를 한 번에, 원자적으로(atomically) 업데이트할 수 있어, 커서가 튀는 현상을 완벽하게 막을 수 있습니다.
5. 완벽한 솔루션: 포맷팅과 커서 위치 동시 제어
위에서 세운 전략을 바탕으로, 가격 포맷팅 코드를 다시 작성해 보겠습니다. 가장 까다로운 부분은 '새로운 커서 위치 계산'입니다.
예를 들어, 사용자가 '123'을 입력한 상태에서(커서는 3 뒤에 있음, offset=3), '4'를 입력했다고 가정해 봅시다.
- 변경 전: `text: '123'`, `selection.extentOffset: 3`
- 사용자가 '4'를 입력. 리스너가 감지하는 순간의 컨트롤러 값: `text: '1234'`, `selection.extentOffset: 4`
- 포맷팅 후: `text`는 '1,234'가 됩니다.
- 커서 위치 계산: 원래 텍스트 '1234'의 길이는 4, 포맷팅된 텍스트 '1,234'의 길이는 5입니다. 길이가 1만큼 늘어났습니다. 따라서 원래 커서 위치였던 4에 늘어난 길이 1을 더해 새로운 커서 위치는 5가 되어야 합니다. 즉, '1,234'에서 4 뒤에 커서가 위치하게 됩니다.
이 논리를 일반화하여 코드로 구현해 보겠습니다.
// ... State 클래스 내부 ...
final _priceController = TextEditingController();
@override
void initState() {
super.initState();
_priceController.addListener(_formatPriceWithCursor);
}
void _formatPriceWithCursor() {
// 현재 컨트롤러의 값(텍스트와 커서 위치 포함)을 가져옴
final text = _priceController.text;
final selection = _priceController.selection;
// 쉼표를 제거한 순수 숫자 문자열
String plainNumber = text.replaceAll(',', '');
if (plainNumber.isEmpty) {
return;
}
try {
final number = int.parse(plainNumber);
final formatter = NumberFormat('#,###');
final newFormattedText = formatter.format(number);
// 텍스트가 실제로 변경되었을 때만 업데이트
if (newFormattedText != text) {
// 새로운 커서 위치 계산
// 포맷팅으로 인해 텍스트 길이가 얼마나 변했는지 계산
final newTextLength = newFormattedText.length;
final oldTextLength = text.length;
final lengthDifference = newTextLength - oldTextLength;
// 원래 커서 위치에 길이 변화량을 더해줌
final newSelectionOffset = selection.baseOffset + lengthDifference;
// 계산된 커서 위치가 텍스트 길이를 벗어나지 않도록 보정
final newSelection = TextSelection.collapsed(
offset: newSelectionOffset > newFormattedText.length
? newFormattedText.length
: newSelectionOffset,
);
// controller.value를 사용하여 텍스트와 커서 위치를 한 번에 업데이트
_priceController.value = TextEditingValue(
text: newFormattedText,
selection: newSelection,
);
}
} catch (e) {
// 예외 처리
}
}
// ... dispose 관련 코드 ...
이제 이 코드를 실행하면, 숫자를 입력할 때마다 쉼표가 자연스럽게 추가되면서도 커서는 항상 입력된 숫자 바로 뒤에 머물러 있게 됩니다. 사용자는 아무런 방해 없이 연속적으로 숫자를 입력할 수 있습니다. 이것이 바로 사용자를 배려한 좋은 UX입니다.
이 기법은 비단 가격 입력뿐만 아니라, 카드 번호 입력(예: '1234-5678-....'), 전화번호 입력(예: '010-1234-....') 등 실시간으로 특정 포맷을 적용해야 하는 모든 경우에 활용할 수 있는 강력한 해결책입니다.
6. 한 걸음 더: `TextInputFormatter`로 로직 재사용하기
지금까지 만든 가격 포맷팅 로직은 아주 훌륭하게 동작합니다. 하지만 이 로직이 특정 화면의 State
클래스 내부에 묶여있다는 단점이 있습니다. 만약 다른 화면에서도 동일한 가격 입력 필드가 필요하다면, 이 코드를 복사-붙여넣기 해야 할까요? 이는 좋은 설계가 아닙니다.
Flutter는 이러한 '입력 텍스트 포맷팅' 로직을 재사용 가능한 클래스로 만들 수 있도록 TextInputFormatter
라는 추상 클래스를 제공합니다. 우리는 이 클래스를 상속받아 우리만의 포맷터를 만들 수 있습니다.
TextInputFormatter
를 상속받는 클래스는 formatEditUpdate
라는 메서드 하나만 구현하면 됩니다. 이 메서드는 이전 TextEditingValue
(oldValue
)와 사용자의 입력으로 인해 변경된 새로운 TextEditingValue
(newValue
)를 인자로 받아서, 최종적으로 적용될 `TextEditingValue`를 반환합니다.
앞서 우리가 리스너에 작성했던 로직을 그대로 `TextInputFormatter`로 옮겨보겠습니다.
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
class NumberWithCommaFormatter extends TextInputFormatter {
final NumberFormat _formatter = NumberFormat('#,###');
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
// 변경이 없거나, 텍스트가 삭제되어 비어있을 경우 그대로 반환
if (newValue.text.isEmpty) {
return newValue.copyWith(text: '');
}
// 텍스트가 삭제되는 경우(backspace) 포맷팅을 다시 적용
if (newValue.text.length < oldValue.text.length) {
String plainNumber = newValue.text.replaceAll(',', '');
if(plainNumber.isEmpty) return const TextEditingValue(text: "");
final String formatted = _formatter.format(int.parse(plainNumber));
return TextEditingValue(
text: formatted,
// 커서를 적절히 유지. 단순화를 위해 맨 뒤로 보내는 경우가 많음.
selection: TextSelection.collapsed(offset: formatted.length),
);
}
// 텍스트가 추가되는 경우
String plainNumber = newValue.text.replaceAll(',', '');
final String newFormattedText = _formatter.format(int.parse(plainNumber));
// 커서 위치 계산
final int selectionIndex = newValue.selection.end;
final int numCommasOld = oldValue.text.split(',').length - 1;
final int numCommasNew = newFormattedText.split(',').length - 1;
final int commaDifference = numCommasNew - numCommasOld;
int newSelectionIndex = selectionIndex + commaDifference;
// 커서 위치가 텍스트 길이를 초과하지 않도록 보정
if (newSelectionIndex > newFormattedText.length) {
newSelectionIndex = newFormattedText.length;
}
return TextEditingValue(
text: newFormattedText,
selection: TextSelection.collapsed(offset: newSelectionIndex),
);
}
}
이렇게 만들어진 NumberWithCommaFormatter
는 이제 어떤 TextField
에서든 간단하게 적용할 수 있습니다.
// ... build 메서드 내부 ...
TextField(
// controller는 여전히 필요합니다.
controller: _someController,
decoration: const InputDecoration(
labelText: '가격을 입력하세요 (Formatter 사용)',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
// inputFormatters 리스트에 우리가 만든 포맷터를 추가합니다.
inputFormatters: [
FilteringTextInputFormatter.digitsOnly, // 숫자만 입력되도록 강제
NumberWithCommaFormatter(), // 우리의 커스텀 포맷터
],
)
이제 더 이상 State
클래스에 addListener
, removeListener
, 복잡한 포맷팅 함수를 둘 필요가 없습니다. TextField
의 inputFormatters
속성에 포맷터 인스턴스를 넣어주기만 하면 됩니다. 코드가 훨씬 간결해지고, 로직이 캡슐화되어 재사용성이 극대화되었습니다. 이것이 Flutter가 지향하는 선언적 UI와 위젯 조합의 철학을 잘 따르는 방식입니다.
마치며: 디테일이 만드는 사용자 경험의 차이
이 글을 통해 우리는 Flutter의 TextEditingController
를 사용하여 텍스트 입력을 관리하는 여정을 함께했습니다. 단순히 텍스트를 읽고 쓰는 수준을 넘어, 리스너를 통해 실시간으로 입력에 반응하고, 가장 까다로운 문제 중 하나인 '동적 포맷팅 시 커서 점프 현상'을 해결하는 방법을 깊이 있게 탐구했습니다. TextEditingValue
와 TextSelection
의 내부 구조를 이해하고 이를 직접 제어함으로써, 우리는 사용자의 입력 흐름을 방해하지 않는 매끄럽고 직관적인 UX를 만들어낼 수 있었습니다. 더 나아가 TextInputFormatter
를 통해 이 해결책을 재사용 가능한 모범 사례로 승화시키는 방법까지 살펴보았습니다.
훌륭한 애플리케이션은 화려한 기능뿐만 아니라, 사용자가 인지하지 못하는 사소한 부분에서의 세심한 배려가 모여 만들어집니다. 텍스트 입력 필드에서 커서가 제멋대로 움직이지 않도록 잡아주는 것과 같은 디테일이 바로 사용자의 만족도와 앱의 전체적인 품질을 결정짓는 중요한 요소입니다. 오늘 배운 TextEditingController
의 고급 활용법을 여러분의 다음 Flutter 프로젝트에 적용하여, 사용자를 감동시키는 한 차원 높은 수준의 앱을 만들어 보시길 바랍니다.
0 개의 댓글:
Post a Comment