시리즈 ‘웹뷰·네이티브 연동’ 번외. 웹뷰 얘기는 아니지만, 커뮤니티 글쓰기처럼 네이티브 폼이 웹뷰와 섞여 도는 화면에서 만난 UX 버그다. “바텀시트를 열었는데 키보드가 안 내려간다”는 사소해 보이는 오류가, 결국 Flutter 버전 업그레이드까지 끌고 간 이야기.

1. 증상: 키보드가 안 내려간다

커뮤니티 글쓰기 화면. 제목 입력창(TextField)에 포커스가 가서 키보드가 올라온 상태에서, “차량 선택” 같은 바텀시트를 열었다. 그런데 키보드가 안 내려갔다. 바텀시트는 키보드에 반쯤 가려지고, 사용자는 뭘 눌러야 할지 헷갈렸다.

원래대로면 다른 UI가 뜰 때 기존 TextField는 포커스를 잃고(focus out) 키보드가 스르륵 내려가야 한다. 근데 안 내려갔다. 별거 아닌 것 같은데 은근 거슬리는 종류의 버그.

2. 1차 처방: 명시적 unfocus

가장 직접적인 처방부터. 바텀시트를 여는 순간 현재 포커스를 강제로 떼줬다.

// 토픽 선택 바텀시트 열기 전 키보드 내리기
void openTopicBottomSheet() {
  FocusManager.instance.primaryFocus?.unfocus();
  Get.find<AppBottomSheetController>().show(
    (_) => CommunityTopicsBottomSheet(onSelected: (topic) { /* ... */ }),
  );
}

차량 선택 버튼도 같은 처리를 했다.

Widget _addCarButton(bool isFirstItem) {
  return InkWell(
    onTap: () {
      FocusManager.instance.primaryFocus?.unfocus();
      _handleSelectCarBottomSheet(isFirstItem);
    },
    child: /* ... */,
  );
}

이걸로 급한 불은 껐다. 근데 이건 결국 바텀시트 여는 곳마다 unfocus()를 손으로 뿌려주는 거다. 새 바텀시트를 추가할 때마다 까먹으면 또 재발한다. 왜 이 화면들은 자동으로 focus out이 안 됐을까?

3. 진짜 원인: 자체 오버레이 바텀시트

파보니 바텀시트가 문제였다. 회사 앱의 바텀시트는 AppBottomSheetController라는 자체 컨트롤러 + 오버레이 위젯 구조였다. 즉 Flutter의 Navigator 라우트로 뜨는 게 아니라, 현재 화면 위에 위젯을 얹는 방식이었다.

그러니 프레임워크 입장에선 “새 화면(route)이 뜬 게 아님” → 기존 TextField의 포커스를 회수할 이유가 없음. focus out이 자동으로 안 걸리던 근본 원인이 여기 있었다. unfocus()를 뿌리는 건 이 구조적 한계를 손으로 메꾸는 것이었다.

그래서 자체 컨트롤러/오버레이 바텀시트를 걷어내고, 표준 Navigator 기반(showModalBottomSheet push/pop)으로 갈아탔다. 공통 셸도 하나 만들었다.

class AppModal {
  static Future<T?> showSheet<T>({
    required WidgetBuilder builder,
    bool isDismissible = true,
  }) {
    return showModalBottomSheet<T>(
      context: Get.context!,
      isScrollControlled: true,
      isDismissible: isDismissible,
      backgroundColor: Colors.transparent,
      builder: (context) => _AppBottomSheetShell(child: builder(context)),
    );
  }
}

호출부도 콜백 지옥에서 await 한 방으로 정리됐다. 결과를 리턴받는 구조라 흐름이 훨씬 읽힌다.

// Before: 컨트롤러 show + 콜백 안에서 hide + delay
void showFirstCarComparisonBottomSheet() {
  Get.find<AppBottomSheetController>().show((_) => CarComparisonBottomSheet(
    onSelected: (idCargrade, brand, model, modelYear, grade) {
      // ... 값 대입
      Get.find<AppBottomSheetController>().hide();
      Future.delayed(const Duration(milliseconds: 400), () {
        showSecondCarComparisonBottomSheet();
      });
    },
  ));
}

// After: push/pop → 결과를 await
void showFirstCarComparisonBottomSheet() async {
  final result = await AppModal.showSheet<CarComparisonResult>(
    builder: (_) => CarComparisonBottomSheet(buttonText: '첫번째 차량 선택 완료'),
  );
  if (result == null) return;

  firstCompareId.value = result.idCargrade;
  firstCompareBrand.value = result.brand;
  firstCompareModel.value = result.model;
  firstCompareModelYear.value = result.modelYear;
  firstCompareGrade.value = result.grade;

  showSecondCarComparisonBottomSheet();
}

표준 Navigator 라우트로 뜨니, 시트가 올라올 때 프레임워크가 알아서 이전 포커스를 정리해준다. unfocus() 뿌리기가 구조적으로 필요 없어지는 방향이다.

4. 그런데 왜 Flutter 버전 업까지 갔나

여기까지가 UX 버그 해결이다. 근데 이 작업은 사실 더 큰 흐름에 얹혀 있었다. 같은 시기 file_selector 1.1.0을 쓰려는데 이게 Dart SDK ^3.9.0을 요구했다. 그래서 Flutter 버전을 통째로 올려야 했다.

버전 업은 절대 무료가 아니었다. 딸려온 것들:

(1) iOS UISceneDelegate 마이그레이션. iOS 13+에서 생명주기 관리가 UIApplicationDelegateUISceneDelegate로 바뀌었는데, Flutter 앱이 아직 구버전 방식을 쓰고 있었다. Info.plist에 Scene 설정을 추가했다. (Flutter 공식 breaking change 문서)

<!-- ios/Runner/Info.plist -->
<key>UIApplicationSceneManifest</key>
<dict>
  <key>UIApplicationSupportsMultipleScenes</key>
  <false/>
  <!-- ... Scene 구성 -->
</dict>

(2) MinimumOSVersion 정리. AppFrameworkInfo.plist에서 하드코딩된 MinimumOSVersion 13.0을 제거해 pubspec 기준으로 맞췄다.

(3) transitive 의존성 대거 상승. matcher 0.12.17 → 0.12.19, material_color_utilities 0.11.1 → 0.13.0, characters, test_api 등이 줄줄이 올라갔다. pubspec.lock이 한참 갈렸다.

(4) 버전 업 이후 잔불 정리. Gradle wrapper, build.gradle 손보고, desktop(macOS/linux/windows) 관련 미사용 플러그인 등록 코드도 정리했다. melos 워크스페이스 설정도 이때 다듬었다.

💡 “라이브러리 하나 올리려다 Flutter 버전 업, 거기서 iOS 생명주기 마이그레이션까지.” 의존성 상향은 늘 이렇게 도미노다. 그래서 UX 버그 처방(unfocus)과 구조 개선(표준 Navigator 전환)을 이 버전 업 작업과 한 브랜치에서 같이 처리했다. 어차피 회귀 테스트를 한 번에 돌려야 하니까.

정리

  • “키보드가 안 내려간다”의 근본 원인은 자체 오버레이 바텀시트였다. 프레임워크가 새 route로 인식 못 하니 포커스를 회수하지 않았다.
  • unfocus() 뿌리기는 급한 처방이고, 표준 Navigator(showModalBottomSheet)로 전환하는 게 구조적 해결이었다. 덤으로 콜백 지옥도 await로 정리됐다.
  • 이 작업은 file_selector → Dart SDK 요구 → Flutter 버전 업이라는 큰 흐름에 묶여 있었고, iOS UISceneDelegate 마이그레이션과 의존성 대거 상향이 따라왔다.