Flutter

[Flutter] 프로필 설정 UI 구성하기

ima9ine4 2024. 9. 23. 14:11
728x90

앱 개발 프로젝트 중 프로필 설정 UI를 구성해야 하는데, 유튜브에 좋은 샘플이 있어서 따라해봤다. 하지만 유튜브 영상을 보면서 했을 때 마냥 코드만 따라치게 되는 경향이 있다. 뼈대가 없으면 스스로 구현하기 어려워지므로 유튜브에서 구성한 UI의 코드를 리뷰해보고 뜯어본 다음 내 앱에 맞게 변형하는 작업까지 기록해보려 한다. 왜 이 위젯을 사용해서 왜 이렇게 레이아웃을 구성했는지 초점을 두어 분석해보려고 한다.

내가 본 유튜브 영상은 아래 링크이다. 맨 아래에 전체 코드가 있다.

https://www.youtube.com/watch?v=AS183vv0xxU

 

 

📍 GestureDetector 위젯

GestureDetector는 사용자의 제스처(클릭, 더블클릭, 오래 누르기, 드래그 등)를 감지하는 위젯이다. 버튼 같은 위젯의 경우에는 onTap이나 onPressed 같은 속성을 이용하여 동작에 대한 설정을 해줄 수 있는데, 특별한 위젯이 없는 빈 공간은 설정해줄 수가 없다. 그래서 Flutter에서는 InkWell이나 GestureDetector로 위젯이 아닌 것에 대해서도 제스처를 감지할 수 있게 해준다. InkWell과의 차이점은 클릭 애니메이션의 유무라고 한다. 

이 코드에서 GestureDetector는 키보드를 내려주기 위한 용도로 사용되었다. 프로필 설정 UI에서는 닉네임, 전화번호 등 개인정보를 수정할 수 있는 란이 있기에 키보드가 사용된다. 키보드의 '완료' 버튼을 눌러서 키보드를 내릴 수도 있겠지만 더 나은 사용자 경험을 위해서 아무 빈 공간이나 클릭해도 키보드가 내려가도록 한다. 

위 캡쳐 사진의 에뮬레이터를 보면 보라색 박스가 차지하고 있는 부분이 GestureDetector가 감싸는 영역이다. Scaffold의 body로 들어간 Container의 자식 위젯으로 사용되었기에 (padding으로 설정해준 부분을 제외하고) 거의 전체 면적을 차지한다. 

onTap 속성 안에는 FocusScope.of(context).unfocus();라고 되어있다. GestureDetector가 감싸고 있는 영역 내 아무 곳을 클릭하면 Focus되고 있던 곳이 Focus를 잃게 된다. 따라서 닉네임을 입력 중이라 닉네임에 포커스가 되어 있는 상황에서 빈 공간을 클릭하면 포커스 되어있던 닉네임이 포커스를 잃으므로 올라가 있던 키보드도 내려오는 것이다. 이렇게 키보드 내리기를 위한 목적으로 제스처 감지가 필요했으므로 클릭 애니메이션이 필요가 없으니 InkWell이 아닌 GestureDetector를 사용한 것 같다. 

 

📍 ListView 위젯

GestureDetector의 자식 위젯으로는 ListView 위젯이 사용되었다. ListView는 수직 혹은 수평으로 위젯들을 목록처럼 띄울 수 있고, 스크롤이 가능한 위젯이다. 위 캡쳐 사진에서 ListView의 자식 위젯들을 확인할 수 있다. 에뮬레이터에도 시각적으로 선이 구분되어 있다.

 

📍 Stack과 Positioned

프로필 사진을 등록하는 화면에서는 edit icon과 프로필 사진이 겹치게 배치해야 하므로 Stack과 Positioned 위젯이 사용되었다. 중앙 배치를 위한 Center위젯을 먼저 상위 위젯으로 넣고 그 안에 Stack 위젯이 들어가있다. 

Positioned 위젯이 사용된 코드를 보자. 프로필 사진을 파란색 사각형으로 감쌌는데, 저 부분이 Stack이 차지하는 영역이다. Stack의 자식 위젯인 Positioned 위젯이 bottom: 0, right: 0 속성을 주게 되어 우측 아래 구석에 배치되도록 할 수 있다.

 

📍 커스텀 위젯 만들기

빨간색으로 표시된 Positioned 위젯 코드 밑으로는 buildTextField라는 위젯이 있다. TextField는 같은 형태를 반복적으로 사용해야하므로 코드를 매번 다 적어주는 대신 Widget을 직접 따로 만들어 분류했다. 커스텀 위젯이다.

 함수처럼 정의해주면 되는데, Widget 위젯이름(파라미터){ return ~ } 이런 형태로 작성되었다. buildTextField에서는 labelText, placeholder, isPasswordTextField를 인자로 받는다. labelText는 닉네임, 이름, 전화번호 같이 각각의 텍스트필드에 대한 title의 역할을 한다. placeholder는 해당 텍스트필드에 채워져 있는 값이다. isPasswordTextField는 비밀번호를 입력하는 텍스트필드인지에 대한 bool 타입 변수이다. isPasswordTextField를 따로 받는 이유는 비밀번호 입력 시 obscureText를 true로 설정하여 비밀번호가 텍스트 그대로 노출되지 않게 하기 위함이다.

그럼 코드가 거의 끝났다. 맨 마지막에는 버튼 두 개가 Row로 쌓여있는데 하나는 OutlinedButton이고 다른 하나는 ElevatedButton이다. Cancel보다 Save 버튼이 더 선명히 잘 보여야 하므로 Save버튼에는 배경색이 있고 그림자가 있어 약간 떠있는 듯한 시각적 효과를 주는 ElevatedButton을 사용했다.

 

📍 전체 코드

import 'package:flutter/material.dart';

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

  @override
  State<SettingScreen> createState() => _SettingScreenState();
}

class _SettingScreenState extends State<SettingScreen> {
  bool isObscurePassword = true;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text(
          "설정",
          style: TextStyle(
            fontFamily: 'Pretendard',
            fontWeight: FontWeight.bold,
          ),
        ),
        bottomOpacity: 2.0,
        backgroundColor: Colors.white,
        shape: const Border(
            bottom: BorderSide(
          color: Color.fromRGBO(0, 0, 0, 0.2),
          width: 1,
        )),
      ),
      body: Container(
        padding: const EdgeInsets.only(left: 15, top: 20, right: 15),
        child: GestureDetector(
            onTap: () {
              FocusScope.of(context).unfocus();
              print('click');
            },
            child: ListView(children: [
              Center(
                child: Stack(
                  children: [
                    Container(
                      width: 130,
                      height: 130,
                      decoration: BoxDecoration(
                          border: Border.all(width: 2, color: Colors.white),
                          boxShadow: [
                            BoxShadow(
                                spreadRadius: 2,
                                blurRadius: 10,
                                color: Colors.black.withOpacity(0.1))
                          ],
                          shape: BoxShape.circle,
                          image: const DecorationImage(
                            fit: BoxFit.cover,
                            image:
                                AssetImage('assets/images/profile_image.png'),
                          )),
                    ),
                    Positioned(
                        bottom: 0,
                        right: 0,
                        child: Container(
                          height: 40,
                          width: 40,
                          decoration: BoxDecoration(
                              shape: BoxShape.circle,
                              border: Border.all(width: 4, color: Colors.white),
                              color: Colors.blue),
                          child: const Icon(Icons.edit, color: Colors.white),
                        )),
                  ],
                ),
              ),
              const SizedBox(
                height: 30,
              ),
              buildTextField("닉네임", "Imagine", false),
              buildTextField("전화번호", "01012345678", false),
              buildTextField("비밀번호", "hyejin0904", true),
              const SizedBox(
                height: 30,
              ),
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  OutlinedButton(
                    onPressed: () {},
                    style: OutlinedButton.styleFrom(
                        padding: const EdgeInsets.symmetric(horizontal: 50),
                        shape: RoundedRectangleBorder(
                            borderRadius: BorderRadius.circular(20))),
                    child: const Text(
                      "Cancel",
                      style: TextStyle(
                        fontSize: 15,
                        letterSpacing: 2,
                        color: Colors.black,
                      ),
                    ),
                  ),
                  ElevatedButton(
                    onPressed: () {},
                    style: ElevatedButton.styleFrom(
                        padding: const EdgeInsets.symmetric(horizontal: 50),
                        backgroundColor: Colors.blue,
                        shape: RoundedRectangleBorder(
                            borderRadius: BorderRadius.circular(20))),
                    child: const Text("SAVE",
                        style: TextStyle(
                            fontSize: 15,
                            letterSpacing: 2,
                            color: Colors.white)),
                  )
                ],
              )
            ])),
      ),
    );
  }

  Widget buildTextField(
      String labelText, String placeholder, bool isPasswordTextField) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 30),
      child: TextField(
        obscureText: isPasswordTextField ? isObscurePassword : false,
        decoration: InputDecoration(
            suffixIcon: isPasswordTextField
                ? IconButton(
                    icon: const Icon(Icons.remove_red_eye, color: Colors.grey),
                    onPressed: () {
                      setState(() {
                        isObscurePassword = !isObscurePassword;
                      });
                    },
                  )
                : null,
            contentPadding: const EdgeInsets.only(bottom: 5),
            labelText: labelText,
            floatingLabelBehavior: FloatingLabelBehavior.always,
            hintText: placeholder,
            hintStyle: const TextStyle(
                fontSize: 16, fontWeight: FontWeight.bold, color: Colors.grey)),
      ),
    );
  }
}

 

다음 글에서 UI를 변형하고 기능을 추가하는 작업을 업로드해볼 예정이다.

728x90
반응형