티스토리 뷰

개요

 

이름 입력 페이지를 구현할 것이다.

 

이 페이지는 스플래쉬 화면에서 라우팅이 되는데

이름입력을 한 상태라면 홈화면으로 라우팅이 되도록 할 것이다.

 

 

 

Common 위젯

 

저 페이지를 구현하는데 3개의 Common위젯들이 필요하다.

버튼, 텍스트필드, 유효성 검사 메시지 이렇게 3개 만들어주면 된다.

 

 

common_button.dart

class CommonButton extends StatefulWidget {
  const CommonButton({
    super.key,
    required this.text,
    this.width = double.maxFinite,
    this.height = 50,
    this.onPressed,
    this.textColor,
    this.backgroundColor,
    this.disabledBackgroundColor,
    this.borderColor,
    this.borderRadius,
  });

  final double width;
  final double height;
  final void Function()? onPressed;
  final String text;
  final Color? textColor;
  final Color? disabledBackgroundColor;
  final Color? backgroundColor;
  final Color? borderColor;
  final BorderRadiusGeometry? borderRadius;

  @override
  State<CommonButton> createState() => _CommonButtonState();
}

class _CommonButtonState extends State<CommonButton> {
  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: widget.width,
      height: widget.height,
      child: ElevatedButton(
          onPressed: widget.onPressed,
          style: ElevatedButton.styleFrom(
            shadowColor: Colors.transparent,
            surfaceTintColor: Colors.transparent,
            backgroundColor: widget.backgroundColor ??
                (context.isLight ? LightModeColors.blue : DarkModeColors.blue),
            disabledBackgroundColor: widget.disabledBackgroundColor ??
                (context.isLight
                    ? LightModeColors.dark3
                    : DarkModeColors.dark3),
            shape: RoundedRectangleBorder(
              borderRadius: widget.borderRadius ?? BorderRadius.circular(8.0),
              side: BorderSide(
                color: widget.borderColor ?? Colors.transparent,
              ),
            ),
          ),
          child: Text(widget.text,
              style: TextStyles.b2Medium
                  .copyWith(color: widget.textColor ?? Colors.white))),
    );
  }
}

CommonButton클래스는 ElevatedButton을 반환한다.

 

버튼을 SizedBox로 감싸면 SizedBox의 크기에 따라 버튼의 크기가 정해진다.

버튼 기능, 색상, 모양등을 프로퍼티로 정의할 수 있도록 구현했다.

 

common_text_field.dart

import 'package:escape_anchovy/res/text/colors.dart';
import 'package:escape_anchovy/res/text/styles.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class CommonTextField extends StatefulWidget {
  const CommonTextField(
      {super.key,
      this.height = 45.0,
      this.hintText = '',
      this.maxLength,
      this.onChanged,
      this.controller, 
      this.focusNode, 
      this.textInputType, 
      this.inputFormatters});

  final double height;
  final String hintText;
  final int? maxLength;
  final void Function(String)? onChanged;
  final TextEditingController? controller;
  final FocusNode? focusNode;
  final TextInputType? textInputType;
  final List<TextInputFormatter>? inputFormatters;

  @override
  State<CommonTextField> createState() => _CommonTextFieldState();
}

class _CommonTextFieldState extends State<CommonTextField> {
  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: widget.height,
      child: TextField(
        style: TextStyles.b1Regular.copyWith(decorationThickness: 0.0),
        maxLength: widget.maxLength,
        controller: widget.controller,
        focusNode: widget.focusNode,
        inputFormatters: widget.inputFormatters,
        enableInteractiveSelection: false,
        decoration: InputDecoration(
          counterText: '',
          hintText: widget.hintText,
          hintStyle: TextStyles.b1Regular.copyWith(
              color: context.isLight
                  ? LightModeColors.dark3
                  : DarkModeColors.dark3),
          border: OutlineInputBorder(
            borderSide: BorderSide(
                color: context.isLight
                    ? LightModeColors.dark3
                    : DarkModeColors.dark3,
                width: 1.0),
            borderRadius: BorderRadius.circular(8.0),
          ),
          focusedBorder: OutlineInputBorder(
            borderSide: BorderSide(
                color: context.isLight
                    ? LightModeColors.dark3
                    : DarkModeColors.dark3,
                width: 1.0),
            borderRadius: BorderRadius.circular(6.0),
          ),
        ),
        textAlignVertical: TextAlignVertical.bottom,
        onChanged: widget.onChanged,
        keyboardType: widget.textInputType,
      ),
    );
  }
}

TextField 위젯을 반환한다.

 

 

 

 

이름에 이상한 특수문자 같은 게 들어가는 걸 방지하기 위해 

'enableInteractiveSelection: false' 로 붙여넣기를 비활성화 하였다.

 

 

common_validation_message.dart

class CommonValidationMessage extends StatefulWidget {
  const CommonValidationMessage({
    super.key,
    this.isInputting = false,
    this.isValidation = false,
    this.passText = '',
    this.errorText = '',
  });

  final bool isInputting;
  final bool isValidation;
  final String passText;
  final String errorText;

  @override
  State<CommonValidationMessage> createState() =>
      _CommonValidationMessageState();
}

class _CommonValidationMessageState extends State<CommonValidationMessage> {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const SizedBox(
          height: 4,
        ),
        widget.isInputting
            ? (widget.isValidation
                ? Row(
                    crossAxisAlignment: CrossAxisAlignment.center,
                    children: [
                      const SizedBox(width: 2),
                      SvgPicture.asset(
                        'assets/svg/pass_circle.svg',
                        height: 12,
                      ),
                      const SizedBox(width: 3),
                      Text(widget.passText,
                          style: TextStyles.b4Regular.copyWith(
                            color: context.isLight
                                ? LightModeColors.green
                                : DarkModeColors.green,
                          ))
                    ],
                  )
                : Row(
                    crossAxisAlignment: CrossAxisAlignment.center,
                    children: [
                      const SizedBox(width: 2),
                      SvgPicture.asset('assets/svg/error_circle.svg',
                          height: 12),
                      const SizedBox(width: 3),
                      Text(widget.errorText,
                          style: TextStyles.b4Regular.copyWith(
                              color: context.isLight
                                  ? LightModeColors.red
                                  : DarkModeColors.red))
                    ],
                  ))
            : const SizedBox.shrink(),
      ],
    );
  }
}

유효성 검사 메시지이다.

 

isInputting은 텍스트필드에서 글자를 입력 중이면 true, 아니면 false값을 가진다.

isValidation은 뜻그대로 유효성검사를 통과하면 true, 아니면 false값을 가진다.

 

 

위젯 디자인 팁

 

 

웬만한 건 챗지피티한테 물어보면 나오는데 

 

때때로 지금 버전에서 사용 안하는 이상한 프로퍼티를 쓰라하거나

내가 원하는 대로 구현이 잘 안되는 경우가 있다.

 

 

 

그럴 땐 반환할 위젯을 ctrl + 좌클릭을 하면 

해당 위젯이 구성된 dart파일로 이동한다.

 

여기서 구현할 기능과 밀접해 보이는 이름의 프로퍼티를

하나씩 써보면서 방법을 찾아낼 수도 있다.

 

 

 

이름 입력 페이지 구현

 

 

준비가 끝났다면 페이지와 컨트롤러 파일을 하나씩 만들어준다.

 

 

글자 색 다르게 하기

 

이런 식으로 같은 행에 위치한 글자의 색을 다르게 하는 방법은 2가지가 있다.

 

Row를 사용하거나 Text.rich를 사용하며 되는데

 Row는 글자의 길이가 화면을 넘어가버리면 overflow가 나는 경우가 생겨

Text.rich를 사용하는 것을 권장한다.

 

 

Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      const Text('안녕하세요', style: TextStyles.h1Bold),
      Text.rich(TextSpan(children: [
        TextSpan(
          text: '이름',
          style: TextStyles.h1Bold.copyWith(
              color:
                  context.isLight ? LightModeColors.blue : DarkModeColors.blue),
        ),
        const TextSpan(text: '을 입력해주세요', style: TextStyles.h1Bold)
      ])),
    ],
  );

Text.rich 내에선 TextSpan을 이용한다.

 

 

 

텍스트 필드 유효성 검사

final nameController = TextEditingController();
final nameFocus = FocusNode();
bool isNameInputting = false;
bool isNameValid = false;

final nameKey = GlobalKey<FormState>();

텍스트 필드에서 입력한 이름을 저장하고 

유효성 검사를 진행하기 위해 필요한 변수들을 컨트롤러에 선언해준다.

 

 

void checkNameValidation(String value) {
    isNameValid = value.isNotEmpty;
    notifyListeners();
  }

이름 입력 텍스트필드의 밸리데이션 함수를 컨트롤러에 추가해준다.

위 함수는 입력 여부만 판단하는 기능을 한다.

 

 

CommonTextField(
  maxLength: 8,
  controller: _controller.nameController,
  focusNode: _controller.nameFocus,
  hintText: '이름 입력',
  onChanged: (value) {
    _controller.checkNameValidation(value);
  },
),
CommonValidationMessage(
  isInputting: _controller.isNameInputting,
  isValidation: _controller.isNameValid,
  errorText: '이름을 8자 이하로 입력해주세요',
  passText: '입력이 완료되었습니다',
),

텍스트필드와 바로밑에 유효성 검사 메시지를 배치한다.

 

 

 

@override
  void initState() {
    super.initState();
    _controller.nameFocus.addListener(() {
      _controller.isNameInputting = _controller.nameFocus.hasFocus;
    });
  }

텍스트 필드내에서 글자를 입력중인지 감지하는 코드를 작성해준다.

isNameInputting은 글자를 입력하고 있는 중에만 true값을 가진다.

 

 

SharePreferenceUtil

 

shared_preferences | Flutter package

Flutter plugin for reading and writing simple key-value pairs. Wraps NSUserDefaults on iOS and SharedPreferences on Android.

pub.dev

 

입력한 이름을 저장한 후 불러오는 기능은 shared_preferences 패키지를 이용하여 구현했다.

 

SecureStroage 패키지를 써도 되긴 하는데

시큐어 스토리지는 문자열만 저장이 된다는 단점이 있다.

 

 

 

util에 shared_preferences.util을 추가해주자.

 

 

import 'package:shared_preferences/shared_preferences.dart';

class SharedPreferencesUtil {
  static SharedPreferencesUtil? _singleton;
  static SharedPreferences? _prefs;

  static Future<SharedPreferencesUtil?> getInstance() async {
    if (_singleton == null) {
      var singleton = SharedPreferencesUtil._();
      await singleton._init();
      _singleton = singleton;
    }
    return _singleton;
  }

  SharedPreferencesUtil._();

  Future _init() async {
    _prefs = await SharedPreferences.getInstance();
  }

  static String? getString(String key, {String? defValue = ''}) {
    return _prefs?.getString(key) ?? defValue;
  }

  static Future<bool>? setString(String key, String value) {
    return _prefs?.setString(key, value);
  }

  static bool? getBool(String key, {bool? defValue = false}) {
    return _prefs?.getBool(key) ?? defValue;
  }

  static Future<bool>? setBool(String key, bool value) {
    return _prefs?.setBool(key, value);
  }
}

해당 클래스 내에서 싱글톤 디자인 패턴을 구현했다.

 

원래는 사용할 때마다 sharedPreferences의 인스턴스를Future 함수내에 선언해줘야 하는데

상당히 귀찮으니까 단일 인스턴스 만으로 사용할 수 있도록 해놓은 것이라 보면 된다. 

 

그 밑에는 문자열과 bool형 값을 저장하고 불러오는 함수이다.

키값을 저장하는 함수는 null 오류가 나지않도록 

defValue에서 기본값을 설정해주었다.

 

 

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await SharedPreferencesUtil.getInstance();

  final settingController = SettingController();
  
  runApp(MyApp(settingController: settingController));
}

main에서 getInstance 함수를 호출해

sharedPreferences의 인스턴스를 생성해주고 prefs에 할당해줌으로써

간단하게 키값을 저장할 수 있게 되었다.

 

 

완료 버튼

void savedName(BuildContext context) {
    SharedPreferencesUtil.setBool('inputName', true);
    SharedPreferencesUtil.setString('name', nameController.text);

    Navigator.pushReplacementNamed(context, HomeScreen.routeName);
  }

입력한 이름과 이름 입력 여부를 저장한 뒤

홈 화면으로 라우팅 하는 함수이다.

 

 

CommonButton(
	text: '완료',
	onPressed: _controller.isNameValid
		? () => _controller.savedName(context)
		: null)),

유효성 검사 통과 여부에 따라 버튼을 활성화/비활성화 한다.

 

 

 

위젯 배치

 

텍스트필드 입력 활성화시 유효성 검사 메시지가 

가려지는 이슈가 발생했다.

 

 

Align 위젯을 이용해 위쪽과 아래쪽 위젯을

분리해서 정렬해준 다음

 

위쪽 Align위젯 내에 위치한 위젯들을

밑에서 올라오는 키보드 인한 overflow를 방지하기 위해

SingleChildScorllView로 감싸줘야 한다.

 

 

return Column(
      children: [
        Expanded(
          child: Align(
            alignment: Alignment.topCenter,
            child: SingleChildScrollView(
              reverse: true,
              child: Padding(

마지막으로 SingleChildScrollView의 reverse프로퍼티에 true값을 주면 된다.

 

위젯 배치 같은 경우 지피티한테 질문하기 애매해서 방법 찾느라 꽤나 애먹었다;;

 

 

user_name_screen.dart

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

  static const routeName = '/name';

  @override
  State<UserNameScreen> createState() => _UserNameScreenState();
}

class _UserNameScreenState extends State<UserNameScreen> {
  final _controller = UserNameController();

  @override
  void initState() {
    super.initState();
    _controller.nameFocus.addListener(() {
      _controller.isNameInputting = _controller.nameFocus.hasFocus;
    });
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
        animation: _controller,
        builder: (context, snapshot) {
          return Scaffold(
            body: _buildPage(context),
          );
        });
  }

  Widget _buildPage(BuildContext context) {
    return Column(
      children: [
        Expanded(
          child: Align(
            alignment: Alignment.topCenter,
            child: SingleChildScrollView(
              reverse: true,
              child: Padding(
                padding: const EdgeInsets.symmetric(horizontal: 30),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    const SizedBox(
                      height: 60,
                    ),
                    Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        const Text('안녕하세요', style: TextStyles.h1Bold),
                        Text.rich(TextSpan(children: [
                          TextSpan(
                            text: '이름',
                            style: TextStyles.h1Bold.copyWith(
                                color: context.isLight
                                    ? LightModeColors.blue
                                    : DarkModeColors.blue),
                          ),
                          const TextSpan(
                              text: '을 입력해주세요', style: TextStyles.h1Bold)
                        ])),
                      ],
                    ),
                    const SizedBox(
                      height: 50,
                    ),
                    Center(
                        child: SvgPicture.asset(context.isLight
                            ? 'asset/svg/user_icon.svg'
                            : 'asset/svg/dark_user_icon.svg')),
                    const SizedBox(
                      height: 50,
                    ),
                    CommonTextField(
                      maxLength: 8,
                      controller: _controller.nameController,
                      focusNode: _controller.nameFocus,
                      hintText: '이름 입력',
                      onChanged: (value) {
                        _controller.checkNameValidation(value);
                      },
                    ),
                    CommonValidationMessage(
                      isInputting: _controller.isNameInputting,
                      isValidation: _controller.isNameValid,
                      errorText: '이름을 8자 이하로 입력해주세요',
                      passText: '입력이 완료되었습니다',
                    ),
                  ],
                ),
              ),
            ),
          ),
        ),
        Align(
          alignment: Alignment.bottomCenter,
          child: Padding(
            padding: const EdgeInsets.fromLTRB(30, 40, 30, 25),
            child: SizedBox(
                width: double.maxFinite,
                height: 50,
                child: CommonButton(
                    text: '완료',
                    onPressed: _controller.isNameValid
                        ? () => _controller.savedName(context)
                        : null)),
          ),
        )
      ],
    );
  }
}

처음에 화면 그리라하면 위젯 배치 이슈로 참 막막한데 

익숙해지면 나름 할만하다.

 

그래도 css 하던 거 생각하면 플러터는 선녀같달까..

 

 

스플래쉬 라우팅 수정

Future<bool> isNameInput() async {
    final inputName = SharedPreferencesUtil.getBool('inputName');
    return inputName!;
  }

  Future<void> checkInputName(BuildContext context) async {
    await Future.delayed(const Duration(seconds: 3));
    isNameInput().then((value) async {
      if (value) {
        Navigator.pushNamedAndRemoveUntil(
            context, UserNameScreen.routeName, (route) => false);
      } else {
        Navigator.pushNamedAndRemoveUntil(
            context, HomeScreen.routeName, (route) => false);
      }
    });
  }

이름을 입력했다면 홈화면으로

이름을 입력하지 않은 상태라면 이름 입력 화면으로 이동하도록 한다.

 

변수명! <- 해당변수가 null이 아님을 뜻한다.

 

이 작업은 비동기로 이루어지므로 then함수를 사용하였다.

 

 

@override
  void initState() {
    super.initState();
    _controller.moveUp();
    _controller.checkInputName(context);
  }

SplashScreen의 initState에서 이 함수를 실행시킨다.

 

 

 

테스트

 

class HomeController with ChangeNotifier {
  String loadUserName() {
    final userName = SharedPreferencesUtil.getString('name');
    return userName!;
  }
}

name 키값(입력한 유저이름)을 불러운 뒤 반환하는 함수이다.

 

 

 

Widget _buildPage(BuildContext context) {
    return Column(
      children: [
        Center(
          child: Text(_controller.loadUserName()),
        )
      ],
    );
  }

String형을 반환하는 함수라 Text위젯 안에 그대로 호출해주면 된다.

 

 

 

 

입력한 이름이 잘 출력되는 모습이다.

 

앞으로도 키값을 저장하고 불러오는 것을 정말 많이 하게 될 것이다.

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
글 보관함