본문 바로가기
IT/Flutter

[Flutter] setState() or markNeedsBuild() called during build.

by Josh.P 2022. 1. 4.
반응형

Flutter를 이용해서 회원가입을 만들고 있었다.

회원가입 Form의 UI 시나리오는 다음과 같다.

  1. 처음 접속하면 Email Input과 하단의 확인 버튼만 등장한다.
  2. 사용자가 Email을 입력하는 동시에 validation을 체크한다.
  3. validation에 통과하면, 확인 버튼이 enable 된다.
  4. 확인 버튼을 누르면 Password Input이 나타나면서 focus가 password input으로 이동한다.
  5. password input에서도 1-3번과 같이 동작한다.
  6. 확인 버튼을 누르면 핸드폰 인증 화면으로 넘어간다.

이런 시나리오로 구현하기 위해서 아래 방법으로 구현을 진행했다.

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:toy_carrot_market/app/data/repository/signup_repository.dart';
import 'package:toy_carrot_market/style/colors.dart';

class SignupController extends GetxController {
  final SignupRepository signupRepository;

  SignupController({required this.signupRepository});

  final GlobalKey<FormState> signupFormKey = GlobalKey<FormState>();
  final emailController = TextEditingController();
  final emailFocusNode = FocusNode();
  final passwordController = TextEditingController();
  final passwordFocusNode = FocusNode();

  final RxInt _formIndex = 0.obs;
  **final RxBool _isDisableButton = true.obs; // (3)**

  int get formIndex => _formIndex.value;

  bool get isDisableButton => _isDisableButton.value;

  String? emailValidator(String? value) { // (2)
    if (value == null) {
      return null;
    }

    **_isDisableButton.value = true; // (3)**

    if (value.isEmpty) {
      return '이메일 주소를 입력해주세요.';
    }

    if (!value.isEmail) {
      return '올바른 이메일 주소를 입력해주세요.';
    }

        **_isDisableButton.value = false;**
    return null;
  }

  ...생략

  @override
  void onClose() {
    emailController.dispose();
    passwordController.dispose();
    passwordFocusNode.dispose();

    super.onClose();
  }
}

class SignupPage extends GetView<SignupController> {
  SignupPage({Key? key}) : super(key: key);

  final List<String> titleList = ['이메일 주소를 입력하세요.', '비밀번호를 입력하세요.'];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        elevation: 0.0,
      ),
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20.0),
          child: Obx(
            () => Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                ...생략,
                Form(
                  key: controller.signupFormKey,
                  **autovalidateMode: AutovalidateMode.onUserInteraction, // (1)**
                  child: Expanded(
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [
                        Column(
                          children: SignupPages.pages.sublist(0, controller.formIndex + 1).map((item) => item).toList(),
                        ),
                        ElevatedButton(
                          **onPressed: controller.isDisableButton ? null : controller.handleSignup, // (3)**
                          style: ...생략
                          ),
                          child: const Text('확인'),
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class SignupPages {
  static final pages = [
    const EmailInputWidget(),
  ];
}

class EmailInputWidget extends GetView<SignupController> {
  const EmailInputWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return TextInputWidget(
      labelText: '이메일 주소',
      textEdintingController: controller.emailController,
      focusNode: controller.emailFocusNode,
      validator: controller.emailValidator,
    );
  }
}

class TextInputWidget extends StatelessWidget {
  final String labelText;
  final TextEditingController textEdintingController;
  final String? Function(String?) validator;
  final bool obscureText;
  final FocusNode focusNode;

  const TextInputWidget({
    required this.labelText,
    required this.textEdintingController,
    required this.validator,
    required this.focusNode,
    this.obscureText = false,
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.only(
        top: 5.0,
        bottom: 10.0,
      ),
      child: TextFormField(
        controller: textEdintingController,
        **validator: validator, // (2)**
        autofocus: true,
        focusNode: focusNode,
        obscureText: obscureText,
        decoration: ...생략,
      ),
    );
  }
}

(1) 사용자가 입력하는 동시에 validation을 체크할 수 있도록 autovalidationMode를 onUserInteraction으로 적용했다.

(2) Email validation 함수를 만들고, Email 입력 값을 체크했다.

(3) Validation 체크에 따라서 확인 버튼이 enable/disable 될 수 있도록 isDisableButton 값을 RxBool로 지정하였다.

이렇게 구현을 하고, 실행을 했을 때, 아래의 에러가 발생한다.

setState() or markNeedsBuild() called during build.

이 에러가 발생하는 원인은 다음과 같다.

Flutter의 Lifecycle 중, build 단계에서 setState()를 실행하면 다음과 같은 에러가 발생한다.

위 코드에서 사용자가 입력하는 행위에 따라서 build가 다시 실행되는데, build가 완료되기 전에 버튼에 걸려있는 isDisableButton의 값을 변경하는 setState()가 실행되었기 때문이다.

에러의 원인은 쉽게 찾아볼 수 있었는데, 이를 해결하기 위해서 어떤 작업을 해야하는지 도저히 감이 잡히지 않았다.

이런저런 구글링 끝에 해결방안을 찾았고, 그 해결 방법은 Flutter의 Future.microtask를 이용하는 방법이다.

Future.microtask는 scheduleMicrotask를 사용해서 비동기적으로 동작하는 함수이고, 매개변수로 주어진 함수를 build가 완료된 후에 호출한다.

그렇기 때문에 위 코드에서 validation 부분을 아래와 같이 작성하게 되면, 사용자가 입력을 하고, build 실행이 완료된 후에 setState가 실행된다.

String? emailValidator(String? value) {
    if (value == null) {
      return null;
    }

    **Future.microtask(() => _isDisableButton.value = true);**

    if (value.isEmpty) {
      return '이메일 주소를 입력해주세요.';
    }

    if (!value.isEmail) {
      return '올바른 이메일 주소를 입력해주세요.';
    }

    **Future.microtask(() => _isDisableButton.value = false);**
    return null;
  }

이렇게 구현하면 처음에 생각했던 시나리오대로 사용자의 입력과 동시에 validation을 체크할 수 있고, validation을 모두 통과했을 때, 버튼을 enable 할 수 있다.

 

 

P.S. 문제는 해결했는데, 아직까지도 저 방법이 가장 효율적인 방법인지는 모르겠다. 계속 찾아보고 찾아보고 찾아보자!!!

P.S. tistory 코드블럭에 dart가 없네... 아쉽..

반응형

댓글