Flutter 뒤로가기로 입력이 사라질 때: PopScope와 나가기 확인

편집 화면에서 뒤로가기를 눌렀는데 입력이 사라졌다면, 입력값을 받는 코드보다 화면을 닫을 수 있는 조건과 닫기 요청 경로부터 확인하세요. Android 뒤로가기, AppBar 버튼, 직접 만든 닫기 버튼이 서로 다른 경로를 쓰면 한 곳에 넣은 확인 창이 다른 곳에서는 나오지 않을 수 있습니다.

이 글은 하나의 Navigator에서 MaterialPageRoute로 연 편집 화면 하나를 다룹니다. 입력값은 최초 문자열과 비교하며, 바뀐 내용이 있을 때만 나가기 확인을 받습니다. 서버 저장, 앱 강제 종료 복원, 선언형 Router와 중첩 Navigator는 아래 예제의 범위 밖입니다.

어떤 뒤로가기에서 무엇이 사라지는지 먼저 기록하세요

현상 먼저 나눌 조건 다음 확인
수정하지 않아도 항상 확인 창이 뜸 변경 여부를 실제 기준값과 비교하는지 입력 이벤트 횟수 대신 현재 값과 기준값 비교
콜백에서 false를 반환했는데 이미 닫힘 이전 onWillPop 방식과 결과 통지를 혼동했는지 canPop을 미리 정하고 didPop을 확인
AppBar는 확인하지만 본문의 닫기는 바로 이동 직접 Navigator.pop을 부르는 경로가 남았는지 모든 명시적 닫기를 같은 함수로 연결
확인 창이 여러 개 쌓이거나 화면이 두 번 닫힘 중복 요청과 이미 완료된 pop을 구분하는지 대화상자 진행 플래그·didPop 분기 확인
iPhone 가장자리 스와이프에서 콜백이 안 옴 사용 중인 route 전환과 canPop 값 플랫폼 제스처 조건과 명시적 뒤로 버튼 확인
복귀 후에는 남지만 앱 재실행 후 사라짐 화면 이동 보호와 임시 저장을 구분하는지 저장 위치·복원 정책을 별도 설계

키보드가 열린 경우에는 키보드만 닫혔는지 편집 route까지 닫혔는지도 구분해 기록하세요. 화면이 보인다는 사실만으로 메모가 디스크나 서버에 저장됐다고 판단하지 않습니다.

canPop은 미리 정하고 didPop은 결과로 읽으세요

공식 predictive back 전환 문서는 뒤로가기 도중 비동기로 허용 여부를 정하는 이전 방식에서, 미리 정한 상태로 가능 여부를 알리는 방식으로 바뀐 배경을 설명합니다. 과거 전환 문서의 코드만 복사하기보다 프로젝트 SDK의 현재 API도 함께 확인하세요.

onPopInvokedWithResult는 pop 요청이 처리된 뒤 받는 통지입니다. 이미 성공한 이동을 콜백의 반환값으로 되돌릴 수 없습니다. 변경 내용이 있을 때는 미리 canPop: false 상태를 유지하고, 차단된 요청에서 사용자 선택을 받아야 합니다. didPop이 true이면 같은 화면을 다시 pop하지 않습니다.

canPop이 false인 Android 제스처에는 predictive back 이동 애니메이션이 적용되지 않습니다. 같은 route에 여러 PopScope가 있다면 모두 허용해야 하는 조건도 있으므로, didPop이 false라는 이유만으로 다른 보호 장치를 무시하고 무조건 닫지 마세요.

변경 여부와 확인 창 중복을 나눈 설명용 예제

아래 코드는 lib/main.dart에 놓고 비교할 수 있도록 작성한 설명용 예제입니다. Flutter SDK 빌드·위젯 테스트·Android/iPhone 실기기 실행은 하지 않았습니다. 홈에서 편집 화면을 push하므로 편집 화면을 앱의 최초 route로 삼는 경우와 구분합니다. 기준값은 ‘처음 메모’이며 저장 기능은 없습니다.

import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: NoteHome()));

class NoteHome extends StatelessWidget {
  const NoteHome({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('메모 시작')),
      body: Center(
        child: FilledButton(
          onPressed: () => Navigator.of(context).push<void>(
            MaterialPageRoute<void>(builder: (_) => const NoteEditor()),
          ),
          child: const Text('편집 화면 열기'),
        ),
      ),
    );
  }
}

class NoteEditor extends StatefulWidget {
  const NoteEditor({super.key});

  @override
  State<NoteEditor> createState() => _NoteEditorState();
}

class _NoteEditorState extends State<NoteEditor> {
  static const _initialText = '처음 메모';
  final _note = TextEditingController(text: _initialText);
  bool _dirty = false;
  bool _dialogOpen = false;

  @override
  void initState() {
    super.initState();
    _note.addListener(_syncDirty);
  }

  void _syncDirty() {
    final changed = _note.text != _initialText;
    if (changed != _dirty) setState(() => _dirty = changed);
  }

  Future<void> _requestLeave() async {
    if (!mounted || _dialogOpen) return;
    final route = ModalRoute.of(context);
    if (route?.isCurrent != true) return;
    final navigator = Navigator.of(context);
    if (!_dirty) {
      navigator.pop<void>();
      return;
    }

    _dialogOpen = true;
    bool? discard;
    try {
      discard = await showDialog<bool>(
        context: context,
        useRootNavigator: false,
        builder: (dialogContext) => AlertDialog(
          title: const Text('변경 내용을 버릴까요?'),
          content: const Text('이 예제는 메모를 저장하지 않습니다.'),
          actions: [
            TextButton(
              onPressed: () => Navigator.of(dialogContext).pop<bool>(false),
              child: const Text('계속 편집'),
            ),
            TextButton(
              onPressed: () => Navigator.of(dialogContext).pop<bool>(true),
              child: const Text('버리고 나가기'),
            ),
          ],
        ),
      );
    } finally {
      _dialogOpen = false;
    }
    if (!mounted || discard != true || route?.isCurrent != true) return;
    navigator.pop<void>();
  }

  @override
  void dispose() {
    _note.removeListener(_syncDirty);
    _note.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return PopScope<void>(
      canPop: !_dirty,
      onPopInvokedWithResult: (didPop, result) {
        if (didPop || !_dirty) return;
        _requestLeave();
      },
      child: Scaffold(
        appBar: AppBar(
          title: const Text('메모 편집'),
          leading: IconButton(
            tooltip: '뒤로',
            icon: const Icon(Icons.arrow_back),
            onPressed: _requestLeave,
          ),
        ),
        body: SafeArea(
          child: ListView(
            padding: const EdgeInsets.all(16),
            children: [
              TextField(
                controller: _note,
                minLines: 3,
                maxLines: 6,
                decoration: const InputDecoration(labelText: '메모'),
              ),
              const SizedBox(height: 16),
              Text(_dirty ? '변경 내용 있음' : '초기값과 같음'),
              TextButton(
                onPressed: _requestLeave,
                child: const Text('닫기'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

controller listener는 현재 문자열이 최초 값과 다른지 비교할 때만 dirty 상태를 갱신합니다. 사용자가 바꿨다가 최초 문자열로 정확히 되돌리면 다시 false가 됩니다. 공백도 메모의 일부로 보며 trim하지 않습니다. 실제 앱에서는 서버에서 읽은 기준값, 저장 완료 시점과 저장 도중 추가 편집을 별도로 다뤄야 합니다.

AppBar와 본문 닫기는 _requestLeave로 모았습니다. 변경이 없으면 바로 닫고, 변경이 있으면 _dialogOpen으로 확인 창 중복을 막습니다. 시스템 요청이 차단된 콜백에서도 같은 함수를 사용하되, didPop이 true이거나 이 예제의 변경 조건이 없으면 추가 작업을 하지 않습니다.

대화상자 닫기와 편집 화면 닫기를 구분하세요

showDialog는 선택값을 Future로 돌려줍니다. 예제는 계속 편집을 false, 버리고 나가기를 true로 돌려주고, 바깥 영역이나 뒤로가기로 대화상자가 취소돼 null이 오면 편집 화면에 남습니다. 닫기 버튼이 어느 Navigator의 어느 route를 닫는지도 명확해야 합니다.

예제는 대화상자에 useRootNavigator: false를 지정하고 대화상자 자신의 context로 선택을 반환합니다. 기다리는 동안 편집 화면이 제거됐거나 다른 route가 위에 올라왔을 수 있으므로 mounted와 기존 route의 isCurrent도 확인합니다. mounted가 true라는 사실만으로 현재 맨 위 화면이 자신이라고 단정하지 않습니다.

사용자가 버리기를 명시적으로 선택한 뒤의 Navigator.pop은 확인된 닫기 동작입니다. 이 호출을 보호 조건이 알아서 막아 줄 것이라 기대해 여러 버튼에 흩어 놓지 마세요. 이 흐름은 공식 전환 문서의 확인 후 pop 방식과 같은 방향이며, 다른 차단 규칙이나 중첩 라우터를 사용하는 앱의 공통 닫기 규칙으로 그대로 확대하지 않습니다.

플랫폼과 Navigator 구조가 다르면 범위를 다시 정하세요

PopScope API는 iOS의 CupertinoRouteTransitionMixin에서 canPop이 false이면 제스처가 감지되지 않아 결과 콜백도 호출되지 않는 경우를 명시합니다. 모든 플랫폼에서 스와이프가 확인 창을 연다고 가정하지 말고, 사용자가 누를 수 있는 뒤로·닫기 경로를 제공하세요. PopScope의 타입 인자는 route의 결과 타입과 맞거나 그 상위 타입이어야 합니다.

Form을 중심으로 이미 변경 상태를 관리한다면 Form.canPop과 관련 결과 콜백도 검토할 수 있습니다. 같은 화면에 PopScope와 Form 보호를 겹쳐 넣기 전에 책임 위치부터 정하세요. 중첩 Navigator는 어느 stack이 닫혀야 하는지 먼저 정하고 해당 구조의 공식 API를 따로 확인해야 합니다.

Navigator.maybePop의 true는 요청을 처리했다는 뜻이며, route가 실제로 제거됐다는 뜻과 항상 같지 않습니다. 차단하고 요청을 처리한 경우도 true일 수 있습니다. 검증에는 반환값 하나 대신 실제 남은 화면과 didPop을 함께 사용하세요.

적용 후에는 입력값과 닫힌 화면 수를 함께 확인하세요

아래는 프로젝트에서 수행할 검증 계획이며 실제 실행 결과가 아닙니다. 테스트용 메모만 사용하고, 각 행은 편집 화면을 새로 연 조건에서 비교하세요.

조건 의도한 동작 기록할 항목
초기값을 그대로 두고 뒤로 확인 창 없이 편집 화면 한 개만 닫음 홈 복귀, 남은 route, didPop
메모를 수정하고 계속 편집 선택 편집 화면과 수정 문자열을 유지 controller 값과 dirty 상태
메모 수정 후 확인 창을 바깥 영역·뒤로로 취소 선택값 null을 나가기 동의로 처리하지 않음 대화상자만 닫혔는지
메모 수정 후 버리고 나가기 선택 편집 화면 한 개만 닫음 추가 pop·추가 대화상자가 없는지
확인 창이 열린 동안 빠르게 뒤로·닫기 반복 확인 창이 중복되지 않음 대화상자 수와 실제 닫힌 route 수
수정했다가 최초 문자열로 정확히 되돌림 dirty=false로 다시 계산 입력 이벤트 수와 무관하게 값 기준 판정
Android 버튼·제스처와 iOS 버튼·제스처 각 플랫폼의 허용 조건에 맞게 처리 route 전환·SDK·canPop·콜백 발생 여부

저장을 추가한다면 저장 중 닫기, 저장 실패, 저장 요청 뒤 추가로 입력한 값까지 별도 행으로 늘리세요. 저장 요청을 시작했다는 이유만으로 dirty를 지우면 아직 저장되지 않은 입력을 잃을 수 있습니다. 앱 강제 종료나 Web 새로고침에도 메모가 필요하면 route 보호와 별개로 임시 저장·복원을 설계해야 합니다.

확인한 자료와 실행 범위

2026년 10월 24일 아래 Flutter 공식 원문을 직접 읽고 API 조건을 대조했습니다. 판단표와 예제는 이를 바탕으로 작성한 설명 자료이며 실제 SDK·기기의 성공 결과를 보고한 것이 아닙니다. 프로젝트 Flutter 버전과 route 구조에서 분석·동작 검증이 필요합니다.

이어서 확인할 가이드