ChangeNotifier의 addListener를 이용한 action 처리

소밍·2023년 9월 9일
post-thumbnail

state의 value가 변할 때 UI를 변경시키는 것이 아닌 dialog, snackbar, bottom sheet, navigation 등과 같은 action을 처리하는 것은 주의가 요구됨.

📌 ChangeNotifier - addListener 이용하지 않고 action 처리하기

  • 원하는 action
    form을 제출했을 때
    • 성공이면 페이지 이동
    • 실패면 dialog 표시

1. rethrow

► app_provider.dart

class AppProvider with ChangeNotifier {
  AppState _state = AppState.initial;
  AppState get state => _state;

  Future<void> getResult(String searchTerm) async {
    _state = AppState.loading;
    notifyListeners();

    await Future.delayed(Duration(seconds: 1));

    try {
      if (searchTerm == 'fail') {
        throw 'Something went wrong';
      }
      _state = AppState.success;
      notifyListeners();
    } catch (e) {
      _state = AppState.error;
      notifyListeners();
      // rethrow;
    }
  }
}

► main.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import 'app_provider.dart';
import 'success_page.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<AppProvider>(
      create: (_) => AppProvider(),
      child: MaterialApp(
        title: 'addListener of ChangeNotifier',
        debugShowCheckedModeBanner: false,
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: const MyHomePage(),
      ),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key}) : super(key: key);

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final GlobalKey<FormState> formKey = GlobalKey<FormState>();
  // form을 핸들링할거기때문에 FormState 타입의 GlobalKey 변수 formKey 선언
  AutovalidateMode autovalidateMode = AutovalidateMode.disabled;
  // form input validation을 위한 AutovalidateMode 타입의 autovalidateMode 변수 선언, 초기값으로 disabled 할당
  String? searchTerm;

  void submit() async {
    // getresult 호출할건데 getresult가 future void 타입이니까 async -

    setState(() {
      autovalidateMode = AutovalidateMode.always;
      // 일단 한번 form이 submit되고 나서 모든 form input에 대해 항상 validation을 수행한다는 것
    });
    final form = formKey.currentState;
    if (form == null || !form.validate()) return;

    form.save();

    try {
      await context.read<AppProvider>().getResult(searchTerm!);
      // getresult의 결과가 성공이면 success 페이지로 이동
      Navigator.push(context, MaterialPageRoute(
        builder: (context) {
          return SuccessPage();
        },
      ));
    } catch (e) {
      showDialog(
        context: context,
        builder: (context) {
          return AlertDialog(
            content: Text('Something went wrong'),
          );
        },
      );
    }
  }

  
  Widget build(BuildContext context) {
    final appState = context.watch<AppProvider>().state;

    return Scaffold(
        body: Center(
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 30.0),
            child: Form(
              key: formKey,
              autovalidateMode: autovalidateMode,
              child: ListView(
                shrinkWrap: true,
                children: [
                  TextFormField(
                    autofocus: true,
                    decoration: InputDecoration(
                      border: OutlineInputBorder(),
                      label: Text('Search'),
                      prefixIcon: Icon(Icons.search),
                    ),
                    validator: (String? value) {
                      if (value == null || value.trim().isEmpty) {
                        return 'Search term required';
                      }
                      return null;
                    },
                    onSaved: (String? value) {
                      searchTerm = value;
                    },
                  ),
                  const SizedBox(height: 20.0),
                  ElevatedButton(
                    onPressed: appState == AppState.loading ? null : submit,
                    child: Text(
                      appState == AppState.loading ? 'Loading' : 'Get Result',
                      style: TextStyle(fontSize: 24.0),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      );
  }
}

코드 실행시 fail값을 입력해도 success 화면으로 넘어가는데
이는 provider의 try catch 중 catch처리 부분을 보면 state만 바꿨을 뿐
에러를 throw하지 않았기에 submit함수에서 에러를 캐치하지 못하고 있는 것.
때문에 rethrow를 추가하면 정상 작동함.


2. rethrow 사용하지 않고 main 함수의 build에서 처리

  • provider 코드의 catch문에 rethrow를 작성하지 않고, main함수내 submit함수 수정
  • submit함수에서 state의 변화를 보고, build함수 내에서 Navigation할지 showDialog할지 결정
  • 이 방법은 유효하지만 빌드내에서 다이얼로그를 표시하거나 내비게이션 하는 것과 같은 액션을 하는 것은 위험할 수 있음
  • GestureDetector를 이용하여 unfocus 하는 기능을 추가할 경우 원하는 동작이 정상적으로 이루어지지 않음

► app_provider.dart

import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'success_page.dart';

enum AppState {
  initial,
  loading,
  success,
  error,
}

class AppProvider with ChangeNotifier {
  AppState _state = AppState.initial;
  AppState get state => _state;

  Future<void> getResult(String searchTerm) async {
    _state = AppState.loading;
    notifyListeners();

    await Future.delayed(Duration(seconds: 1));

    try {
      if (searchTerm == 'fail') {
        throw 'Something went wrong';
      }
      _state = AppState.success;
      notifyListeners();
    } catch (e) {
      _state = AppState.error;
      notifyListeners();
    }
  }
}

► main.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import 'app_provider.dart';
import 'success_page.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<AppProvider>(
      create: (_) => AppProvider(),
      child: MaterialApp(
        title: 'addListener of ChangeNotifier',
        debugShowCheckedModeBanner: false,
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: const MyHomePage(),
      ),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key}) : super(key: key);

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final GlobalKey<FormState> formKey = GlobalKey<FormState>();
  AutovalidateMode autovalidateMode = AutovalidateMode.disabled;
  String? searchTerm;

  void submit() {
    setState(() {
      autovalidateMode = AutovalidateMode.always;
    });
    final form = formKey.currentState;
    if (form == null || !form.validate()) return;

    form.save();

    context.read<AppProvider>().getResult(searchTerm!);
  }

  
  Widget build(BuildContext context) {
    final appState = context.watch<AppProvider>().state;
    if(appState == AppState.success){
      WidgetsBinding.instance!.addPostFrameCallback((_) {
        Navigator.push(context, MaterialPageRoute(
          builder: (context) {
            return SuccessPage();
          },
        ));
      });
    } else if(appState == AppState.error){
      WidgetsBinding.instance!.addPostFrameCallback((_) {
        showDialog(
          context: context,
          builder: (context) {
            return AlertDialog(
              content: Text('Something went wrong'),
            );
          },
        );
      });
    }

    return GestureDetector(
      onTap: () => FocusScope.of(context).unfocus(),
      child: Scaffold(
        body: Center(
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 30.0),
            child: Form(
              key: formKey,
              autovalidateMode: autovalidateMode,
              child: ListView(
                shrinkWrap: true,
                children: [
                  TextFormField(
                    autofocus: true,
                    decoration: InputDecoration(
                      border: OutlineInputBorder(),
                      label: Text('Search'),
                      prefixIcon: Icon(Icons.search),
                    ),
                    validator: (String? value) {
                      if (value == null || value.trim().isEmpty) {
                        return 'Search term required';
                      }
                      return null;
                    },
                    onSaved: (String? value) {
                      searchTerm = value;
                    },
                  ),
                  const SizedBox(height: 20.0),
                  ElevatedButton(
                    onPressed: appState == AppState.loading ? null : submit,
                    child: Text(
                      appState == AppState.loading ? 'Loading' : 'Get Result',
                      style: TextStyle(fontSize: 24.0),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

위 에러 처리

  • GestureDetector 동작이 정상처리 되지 않기 때문에
    provider의 getResult에서 context값을 받아 Navigator.push와 showDialog 동작 처리
  • 이 방법은 유효하나 BuildContext, Navigator.push에서 이동할 화면을 import 해야하기 때문에 provider와 ui의 경계가 clear하지 않음
  • 에러가 발생한 그 시점에서 다이얼로그를 표시하지만 로직과 ui가 명확하게 구분되지 않음

► app_provider.dart

import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'success_page.dart';

enum AppState {
  initial,
  loading,
  success,
  error,
}

class AppProvider with ChangeNotifier {
  AppState _state = AppState.initial;
  AppState get state => _state;

  Future<void> getResult(BuildContext context, String searchTerm) async {
    _state = AppState.loading;
    notifyListeners();

    await Future.delayed(Duration(seconds: 1));

    try {
      if (searchTerm == 'fail') {
        throw 'Something went wrong';
      }
      _state = AppState.success;
      notifyListeners();
      Navigator.push(context, MaterialPageRoute(
        builder: (context) {
          return SuccessPage();
        },
      ));
    } catch (e) {
      _state = AppState.error;
      notifyListeners();
      showDialog(
        context: context,
        builder: (context) {
          return AlertDialog(
            content: Text('Something went wrong'),
          );
        },
      );
    }
  }
}

► main.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import 'app_provider.dart';
import 'success_page.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<AppProvider>(
      create: (_) => AppProvider(),
      child: MaterialApp(
        title: 'addListener of ChangeNotifier',
        debugShowCheckedModeBanner: false,
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: const MyHomePage(),
      ),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key}) : super(key: key);

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final GlobalKey<FormState> formKey = GlobalKey<FormState>();
  AutovalidateMode autovalidateMode = AutovalidateMode.disabled;
  String? searchTerm;


  void submit() {
    setState(() {
      autovalidateMode = AutovalidateMode.always;
    });
    final form = formKey.currentState;
    if (form == null || !form.validate()) return;

    form.save();

    context.read<AppProvider>().getResult(context, searchTerm!);
  }

  
  Widget build(BuildContext context) {
    final appState = context.watch<AppProvider>().state;

    return GestureDetector(
      onTap: () => FocusScope.of(context).unfocus(),
      child: Scaffold(
        body: Center(
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 30.0),
            child: Form(
              key: formKey,
              autovalidateMode: autovalidateMode,
              child: ListView(
                shrinkWrap: true,
                children: [
                  TextFormField(
                    autofocus: true,
                    decoration: InputDecoration(
                      border: OutlineInputBorder(),
                      label: Text('Search'),
                      prefixIcon: Icon(Icons.search),
                    ),
                    validator: (String? value) {
                      if (value == null || value.trim().isEmpty) {
                        return 'Search term required';
                      }
                      return null;
                    },
                    onSaved: (String? value) {
                      searchTerm = value;
                    },
                  ),
                  const SizedBox(height: 20.0),
                  ElevatedButton(
                    onPressed: appState == AppState.loading ? null : submit,
                    child: Text(
                      appState == AppState.loading ? 'Loading' : 'Get Result',
                      style: TextStyle(fontSize: 24.0),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

📌 ChangeNotifier - addListener 이용하기

  • state 값에 따라 액션을 처리할 voidcallback을 만듦
  • initState 메서드에서 changeNotifier의 addListener를 이용해 voidCallback을 등록함
  • listener는 저절로 dispose되지 않기 때문에 stateful 위젯에 dispose메서드에서 changeNotifier에 removeListener를 이용해 리스너를 제거함
  • 로직이 clear하지만 listener를 등록하고 dispose해야하는 보일러플레이트 코드 작성 필요

► app_provider.dart

import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'success_page.dart';

enum AppState {
  initial,
  loading,
  success,
  error,
}

class AppProvider with ChangeNotifier {
  AppState _state = AppState.initial;
  AppState get state => _state; 

  Future<void> getResult( String searchTerm) async {
    _state = AppState.loading;
    notifyListeners();

    await Future.delayed(Duration(seconds: 1));

    try {
      if (searchTerm == 'fail') {
        throw 'Something went wrong';
      }
      _state = AppState.success;
      notifyListeners();
    } catch (e) {
      _state = AppState.error;
      notifyListeners();
    }
  }
}

► main.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import 'app_provider.dart';
import 'success_page.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<AppProvider>(
      create: (_) => AppProvider(),
      child: MaterialApp(
        title: 'addListener of ChangeNotifier',
        debugShowCheckedModeBanner: false,
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: const MyHomePage(),
      ),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key}) : super(key: key);

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final GlobalKey<FormState> formKey = GlobalKey<FormState>();
  AutovalidateMode autovalidateMode = AutovalidateMode.disabled;
  String? searchTerm;
  late final AppProvider appProv;

  
  void initState() {
    super.initState();
    appProv = context.read<AppProvider>();
    appProv.addListener(appListener);
  }

  void appListener() {
    // appState의 상태를 체크해야하기 때문에 appProvider의 instance 필요
    if (appProv.state == AppState.success) {
      Navigator.push(context, MaterialPageRoute(
        builder: (context) {
          return SuccessPage();
        },
      ));
    } else if (appProv.state == AppState.error) {
      showDialog(
        context: context,
        builder: (context) {
          return AlertDialog(
            content: Text('Something went wrong'),
          );
        },
      );
    }
  }

  
  void dispose() {
    appProv.removeListener(appListener);
    super.dispose();
  }

  void submit() {
    setState(() {
      autovalidateMode = AutovalidateMode.always;
    });
    final form = formKey.currentState;
    if (form == null || !form.validate()) return;

    form.save();

    context.read<AppProvider>().getResult(searchTerm!);
  }

  
  Widget build(BuildContext context) {
    final appState = context.watch<AppProvider>().state;

    return GestureDetector(
      onTap: () => FocusScope.of(context).unfocus(),
      child: Scaffold(
        body: Center(
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 30.0),
            child: Form(
              key: formKey,
              autovalidateMode: autovalidateMode,
              child: ListView(
                shrinkWrap: true,
                children: [
                  TextFormField(
                    autofocus: true,
                    decoration: InputDecoration(
                      border: OutlineInputBorder(),
                      label: Text('Search'),
                      prefixIcon: Icon(Icons.search),
                    ),
                    validator: (String? value) {
                      if (value == null || value.trim().isEmpty) {
                        return 'Search term required';
                      }
                      return null;
                    },
                    onSaved: (String? value) {
                      searchTerm = value;
                    },
                  ),
                  const SizedBox(height: 20.0),
                  ElevatedButton(
                    onPressed: appState == AppState.loading ? null : submit,
                    child: Text(
                      appState == AppState.loading ? 'Loading' : 'Get Result',
                      style: TextStyle(fontSize: 24.0),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}
profile
생각이 길면 용기는 사라진다.

0개의 댓글