[캡스톤_front] flutter custom keyboard 분석 & 이해하고 적용하기

피용희·2024년 4월 12일
0

2024 캡스톤

목록 보기
16/19

https://velog.io/@sunj_0120/%EC%BA%A1%EC%8A%A4%ED%86%A4front-flutter-custom-keyboard-%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0

저번 시간에 이렇게 커스텀 키보드를 완성했다! 그러나 이걸 어떻게 이런 방식으로 구현했는지 코드를 이해해야 내가 원하는 방식으로 적용할 수 있기 때문에, 코드를 분석해볼 것이다.

코드를 분석한 다음, 현재 추가되어 있는 qr 다음 화면과 합치고, 현재 내가 개발 예정인 우리 어플 화면과 얼추 비슷하게 맞춰볼 예정이다.


1. 분석

KeyboardKey

저번에 말했듯이, 이 키보드는 키와 키보드 view가 나눠져 있는 형태로 구성되어 있다. 일단 키의 구조부터 살펴보자.

우선 전체 코드는 다음과 같다. 여기서 요소들을 하나하나 뜯어서 분석을 진행해보자.

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

class KeyboardKey extends StatefulWidget {
  final dynamic label;
  final dynamic value; //값
  final ValueSetter<dynamic> onTap;

  KeyboardKey({
    required this.label,
    required this.value,
    required this.onTap,
  });

  @override
  State<KeyboardKey> createState() => _KeyboardKeyState();
}

class _KeyboardKeyState extends State<KeyboardKey> {

  renderLabel(){
    if(widget.label is Widget){
      return widget.label;
    }
    return Text(
      widget.label,
      style: TextStyle(
        fontSize: 25.0,
        fontWeight: FontWeight.bold,
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return InkWell(
      onTap: (){
        widget.onTap(widget.value);
      },
      child: Container(
        width :123.429,
        height: 60,
        child: AspectRatio(
          aspectRatio: 2,
          child: Center(
            child : renderLabel(),
          ),
        ),
      ),
    );
  }
}
  1. StatefulWidget
class KeyboardKey extends StatefulWidget {
  final dynamic label;
  final dynamic value; //값
  final ValueSetter<dynamic> onTap;

  KeyboardKey({
    required this.label,
    required this.value,
    required this.onTap,
  });

  @override
  State<KeyboardKey> createState() => _KeyboardKeyState();
}

그동안 봤던 StatefulWidget이 state만을 정의하는 것과는 다르게, 이 속성은 key를 가지고 있다. 보통 StatefulWidget 클래스는 위젯의 상태와 관련된 데이터를 포함하거나 상태 변경을 트리거하는 콜백을 정의하는 역할을 한다.
그런데 이 코드에서는 StatefulWidget 자체가 단순히 상태를 관리하는 역할을 넘어서, 특정 키보드 키를 표현하는 데 사용되고 있다. 즉, 위젯 자체가 키를 표현하고, 해당 키에 대한 라벨, 값 및 탭 이벤트를 처리하기 위한 것이므로 이러한 속성을 StatefulWidget 클래스 내부에 직접 정의하는 것이 적합하다.

즉, 이 코드는 키보드 키를 표현하는 완전한 위젯으로써, 위젯 자체가 가지는 속성들을 분명히하고 상태와 연결된 부분을 구현함으로써 모듈화와 재사용성을 높이는 것을 목표로 하고 있습니다.즉, 상태와 관련된 부분을 state로 분리하고 키를 나타내는 요소들을 넣은 것이다.

  1. state
    이 부분은 좀 길기 때문에 쪼개서 보도록 하자.
class _KeyboardKeyState extends State<KeyboardKey> {

  renderLabel(){
    if(widget.label is Widget){
      return widget.label;
    }
    return Text(
      widget.label,
      style: TextStyle(
        fontSize: 25.0,
        fontWeight: FontWeight.bold,
      ),
    );
  }
  • 이 부분은 _ KeyboardKeyState 클래스 내에 정의된 renderLabel() 메서드를 분석하기 위함이다. 이 메서드는 위젯의 라벨을 렌더링하는 역할을 한다.
    • 렌더링 : 화면에 보여지는 것을 준비하는 과정을 의미한다.
    • 즉, 이는 메서드가 주어진 위젯의 라벨을 화면에 그리는 작업을 수행한다는 것을 의미한다.
  • renderLabel() : 이 메서드는 반환값이 없으며, 대신 위젯의 라벨을 렌더링한다.
  • if(widget.label is Widget)은 라벨이 위젯인지를 확인한다. 즉, 라벨이 텍스트(Text) 위젯 등 Flutter 위젯의 인스턴스인지를 검사하는 것이다.
  • 라벨이 위젯인 경우(widget.label is Widget가 true인 경우), 그대로 해당 위젯을 반환한다.
  • 그렇지 않은 경우 라벨이 위젯이 아닌 경우에는 텍스트(Text) 위젯으로 간주하고, 라벨을 텍스트로 표시한다.
    • 우리 커스텀 키보드의 경우 굳이 widget인지 확인할 필요는 없지만, 추후 확장성을 위해 추가한듯 하다.
@override
  Widget build(BuildContext context) {
    return InkWell(
      onTap: (){
        widget.onTap(widget.value);
      },
      child: Container(
        width :123.429,
        height: 60,
        child: AspectRatio(
          aspectRatio: 2,
          child: Center(
            child : renderLabel(),
          ),
        ),
      ),
    );
  }
}

참고로 말하자면, State 클래스 내에서 build 메서드를 정의하는 것은 StatefulWidget이 현재 상태에 따라 화면을 재구성하고 다시 그리는 방법을 지정하는 것이다.
Flutter는 위젯 트리가 변경되면, 그에 따라 화면이 다시 그려진다. 이러한 변경은 상태가 변경될 때, 사용자 입력이 발생했을 때, 또는 애니메이션 등의 이벤트가 발생했을 때 발생할 수 있다. 이 때, StatefulWidget의 상태가 변경되면 해당 상태를 가지고 있는 State 클래스의 build 메서드가 호출되어 화면을 다시 그린다.
따라서 State 클래스 내의 build 메서드는 해당 상태에 따라 위젯을 어떻게 구성하고 표시할지를 결정한다.

  • InkWell : 이 위젯은 터치 효과를 추가할 수 있는 위젯이다. 여기서는 onTap 콜백이 호출될 때 widget.onTap(widget.value)을 실행하도록 되어 있다. 이때 widget.value는 키보드 키의 값이며, widget.onTap은 키를 탭할 때 실행되는 콜백이다. 즉, 정리하자면 터치 효과를 주는데, 이걸 tap 하면 키보드의 value가 실행된다.
  • AspectRatio : 이 위젯은 자식 위젯의 가로 세로 비율을 조정하는 데 사용된다. 여기서는 자식 위젯의 가로 세로 비율을 2로 설정한다. 이것은 자식 위젯이 부모 위젯에 대해 가로로 더 긴 사각형 모양을 갖도록 한다. 이를 통헤 직사각형 모양의 key 생성이 가능하다.
    • child : renderLabel() : 이를 통해 위에서 정의한 renderLabel()(키 값)들이 AspectRatio 비율을 유지하면서 중앙에 배치된다.

정리하자면, InkWell을 포함하여 터치 효과를 추가하고, 키보드 키를 나타내는 컨테이너를 생성하며, 그 안에 라벨(키 값)을 중앙에 배치하는 위젯을 생성합니다.

CustomKeyboard

다음은 이 키를 이용한 커스텀 키보드를 분석해보자.
일단 전체 코드는 다음과 같다.

import 'package:custom_keyboard_test/CustomKeyboard/KeyboardKey.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';

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

  @override
  State<CustomKeyboard> createState() => _CustomKeyboardState();
}

class _CustomKeyboardState extends State<CustomKeyboard> {
  List<List<dynamic>> keys = [
    ['1', '2', '3'],
    ['4', '5', '6'],
    ['7', '8', '9'],
    ['00', '0', Icon(Icons.keyboard_backspace, size: 30,)],
  ];
  String amount = '';

  @override
  void initState(){
    super.initState();

    // keys = [
    //   ['1', '2', '3'],
    //   ['4', '5', '6'],
    //   ['7', '8', '9'],
    //   ['00', '0', Icon(Icons.keyboard_backspace)],
    // ];
    // amount = ''; //textfield 를 사용하는 경우에는 이걸 쓰면 된다.
  }

  onKeyTap(val){
    if(val == "0" && amount.length == 0){
      return;
    }

    if(val == "00" && amount.length == 0){
      return;
    }

    setState(() {
      amount = amount + val;
    });
  }

  onBackspacePress(){
    if(amount.length == 0){
      return;
    }

    setState(() {
      amount = amount.substring(0, amount.length - 1);
    });
  }


  renderKeyboard() {
    return keys
        .map(
          (x) => Row(
            children: x.map(
              (y) {
                return KeyboardKey(
                    label: y,
                    value: y,
                    onTap: (val) {
                      if(val is Widget){
                        onBackspacePress();
                      }else{
                        onKeyTap(val);
                      }
                    }
                );
              },
            ).toList(),
          ),
        )
        .toList();
  }

  renderAmount() {
    String display = "얼마나 보낼까요?";

    TextStyle textStyle = TextStyle(
      fontSize: 30.0,
      fontWeight: FontWeight.bold,
      color: Colors.grey,
    );

    if(this.amount.length > 0){
      NumberFormat f = NumberFormat("#,###");
      display = "${f.format(int.parse(amount))}매듭";
      textStyle = textStyle.copyWith(
        color: Colors.black,
      );
    }

    return Expanded(
      child: Center(
        child : Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                display,
                style: textStyle,
              ),
              Text(
                  "잔액 : 10,000매듭" //api 요청
              )
            ]
        ),
      )
    );
  }

  renderConfirmButton() {
    //버튼
    return Row(
      children: [
        Expanded(
          child: ElevatedButton(
            onPressed: amount.length > 0 ? (){

            } : null,
            style: ElevatedButton.styleFrom(
                disabledBackgroundColor: Colors.grey[400],
                disabledForegroundColor: Colors.grey,
                foregroundColor : Colors.black,
                backgroundColor: Colors.orange,
            ),

            child: Padding(
              padding: const EdgeInsets.all(20),
              child: Text(
                  "확인",
                style: TextStyle(
                  color: Colors.white,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),
          ),
        ),
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(20.0),
          child: Column(children: [
            renderAmount(),
            ...renderKeyboard(),
            SizedBox(height: 20,),
            renderConfirmButton(),
          ]),
        ),
      ),
    );
  }
}

여기도 마찬가지로 매우 길기 때문에 약간 기능을 쪼개서 보도록 하겠다.

List<List<dynamic>> keys = [
    ['1', '2', '3'],
    ['4', '5', '6'],
    ['7', '8', '9'],
    ['00', '0', Icon(Icons.keyboard_backspace, size: 30,)],
  ];
  String amount = '';

  @override
  void initState(){
    super.initState();

    // keys = [
    //   ['1', '2', '3'],
    //   ['4', '5', '6'],
    //   ['7', '8', '9'],
    //   ['00', '0', Icon(Icons.keyboard_backspace)],
    // ];
    // amount = ''; //textfield 를 사용하는 경우에는 이걸 쓰면 된다.
  }

이 부분은 키보드의 형태를 구성하고, 초기화 시키는 부분이다.
사실 봤던 강의에서는 주석 처리 되어있는 것 처럼 initState안에서 한 번 초기화를 해줬는데, nullable 문제가 생겨서..(flutter에 ?등이 도입된지 그닥 오래 지나지 않았다) 초기화를 밖에서 하는 방향으로 고친 것이다.
지금은 딱히 문제가 없긴한데, 언제 문제가 생길지 감이 안잡혀서 일단 주석으로 지워뒀다.

  • List<List< dynamic >> : 이차원 리스트로 나타낸 것이고, 내부 요소는 icons, string등 다양한 요소가 있으므로 dynamic으로 표현했다.
onKeyTap(val){
    if(val == "0" && amount.length == 0){
      return;
    }

    if(val == "00" && amount.length == 0){
      return;
    }

    setState(() {
      amount = amount + val;
    });
  }

이 코드는 onKeyTap 함수를 정의하고 있다. 이 함수는 키보드에서 키를 탭할 때 호출되며, 전달된 값 val에 따라 특정 작업을 수행한다.

  • val 값이 0인 상태에서 00과 0을 입력할 경우, 입력값이 나오면 안되기 때문에 return을 통해 눌러도 아무런 작업 없이 지나갈 수 있도록 구성했다.

    setState : 상태를 업데이트 하는 것이다. 화면이 다시 그려지며, 이를 통해 값들이 더해진다.

onBackspacePress(){
    if(amount.length == 0){
      return;
    }

    setState(() {
      amount = amount.substring(0, amount.length - 1);
    });
  }

이 부분은 앞에서 키를 눌렀을때 나오는 효과를 정의한것과 비슷하게, backpress를 했을때 나타나는 것을 정의한 것이다.
마찬가지로 길이가 0(입력값이 없음)이면 아무것도 하지 않고, 아니면 amount.substring(0, amount.length - 1)를 실행해서 문자열의 첫번째 문자부터 마지막 문자 하나 전까지의 부분 문자열을 반환한다. 즉, 마지막 문자를 제외한 나머지 문자열을 얻게 된다.
이렇게 새로 정의한 amount값을 다시 저장해서, 화면을 그린다.

  renderKeyboard() {
    return keys
        .map(
          (x) => Row(
            children: x.map(
              (y) {
                return KeyboardKey(
                    label: y,
                    value: y,
                    onTap: (val) {
                      if(val is Widget){
                        onBackspacePress();
                      }else{
                        onKeyTap(val);
                      }
                    }
                );
              },
            ).toList(),
          ),
        )
        .toList();
  }

renderKeyboard() 함수는 keys 리스트를 기반으로 키보드를 생성하는 역할을 한다. 이 함수는 keys 리스트의 각 요소를 반복하면서 각 행(row)의 키를 생성하고 이를 행의 리스트로 묶어 전체 키보드를 반환하는 역할을 한다.

  • keys.map() : 이 메서드는 keys 리스트의 각 요소를 반복하며 새로운 리스트를 생성한다. 여기서 각 요소는 키보드의 한 행을 나타내게 된다.(row)
  • Row : 이 위젯은 키보드의 한 행을 나타내는 위젯이다. Row 위젯의 자식으로는 각 행에 속하는 각 키를 포함하는 리스트인 children이 전달된다.
  • x.map() : 이 메서드는 현재 행의 각 키를 반복하면서 새로운 리스트를 생성한다. 여기서 각 요소는 특정 행의 한 키를 나타낸다.
  • return KeyboardKey : 각 키를 생성하는 과정에서는 이 위젯이 생성된다.(앞에서 만들었던 기본 키들..그것이다.) 이때, 각 키의 라벨(label)은 현재 반복되고 있는 요소인 y로 설정되고, 값(value) 역시 y로 설정된다. 또한, 키를 탭했을 때 실행되는 콜백(onTap)은 해당 키의 값을 전달하면서 onKeyTap 또는 onBackspacePress 함수를 위젯이냐, 아니냐에 따라서 호출한다. 이를 통해 지우기, 입력하기의 기능이 구현될 수 있는 것이다.
  • .toList()를 이용해 각 행(row)를 리스트로 변환하고, 전체 키보드의 형태로 반환한다.
 renderAmount() {
    String display = "얼마나 보낼까요?";

    TextStyle textStyle = TextStyle(
      fontSize: 30.0,
      fontWeight: FontWeight.bold,
      color: Colors.grey,
    );

    if(this.amount.length > 0){
      NumberFormat f = NumberFormat("#,###");
      display = "${f.format(int.parse(amount))}매듭";
      textStyle = textStyle.copyWith(
        color: Colors.black,
      );
    }
    
    return Expanded(
      child: Center(
        child : Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                display,
                style: textStyle,
              ),
              Text(
                  "잔액 : 10,000매듭" //api 요청
              )
            ]
        ),
      )
    );
  }

renderAmount() 함수는 화면에 보여줄 송금 액수를 표시하는 역할을 한다. 함수 내에서는 다음과 같은 작업을 수행한다.

  • display 변수에는 초기값으로 "얼마나 보낼까요?"라는 문자열이 저장된다. 이 문자열은 사용자에게 송금 액수를 입력하라는 안내 메시지를 나타낸다.
  • textStyle은 초기값으로 송금 액수를 표시할 때 사용할 텍스트 스타일이 지정된다.
  • if(this.amount.length > 0) : 이 부분은 송금 액수가 지정되었는지 확인하기 위한 부분이다. length가 0 이상이라 입력값이 있는 경우 다음 작업을 시작한다.
  • NumberFormat 클래스를 사용하여 입력된 송금 액수를 콤마(,)로 천 단위마다 구분하여 표시한다. 이렇게 포멧팅 된 문자열은 f.format(int.parse(amount))를 통해 얻어진다.
  • 최종적으로 expended 위젯을 사용해서 송금액수를 표시하는 영역을 확장하고, 이를 가운데 정렬하기 위해 Center 위젯으로 감싸고, Column 위젯을 사용하여 텍스트와 다른 내용을 세로로 정렬한다.
 renderConfirmButton() {
    //버튼
    return Row(
      children: [
        Expanded(
          child: ElevatedButton(
            onPressed: amount.length > 0 ? (){

            } : null,
            style: ElevatedButton.styleFrom(
                disabledBackgroundColor: Colors.grey[400],
                disabledForegroundColor: Colors.grey,
                foregroundColor : Colors.black,
                backgroundColor: Colors.orange,
            ),

            child: Padding(
              padding: const EdgeInsets.all(20),
              child: Text(
                  "확인",
                style: TextStyle(
                  color: Colors.white,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),
          ),
        ),
      ],
    );
  }

이것은 하단의 "확인"버튼을 나타내기 위한 것이다.

  • Expanded를 통해 남은 공간을 채우도록 되었다.
  • ElevatedButton : 버튼의 활성화 여부는 onPressed 콜백을 통해 결정되며, 여기서는 amount.length > 0 조건을 사용하여 송금 액수가 입력되었을 때만 버튼이 활성화되도록 설정하였다.
  • ElevatedButton.styleFrom을 통해 버튼이 활성화 되지 않았을 때와 활성화 되었을때를 구분해서 분류 가능하다.
@override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(20.0),
          child: Column(children: [
            renderAmount(),
            ...renderKeyboard(),
            SizedBox(height: 20,),
            renderConfirmButton(),
          ]),
        ),
      ),
    );
  }

build 메서드는 화면을 생성하는 역할을 한다. 여기서는 Scaffold 위젯을 사용하여 기본적인 앱 레이아웃을 생성하고, 그 안에 다양한 위젯들을 배치한다.

  • SafeArea 위젯은 화면의 안전 영역에 대한 패딩을 제공한다. 디바이스의 상단 바나 하단 네비게이션 바와 겹치지 않도록 하는 역할을 한다.
  • renderAmount()는 송금 액수를
  • renderKeyboard()는 키보드를
    • ...은 Dart에서 Spread 연산자라고 불리며, 리스트나 맵을 풀어서 해당 위치에 원소를 삽입하는 역할을 한다. 이 연산자를 사용하면 리스트나 맵의 각 원소를 개별적으로 전달할 수 있다.
  • renderConfirmButton()는 버튼을 나타낸다.

2. 적용하기

이제 키보드의 구성요소에 대한 이해가 완료 되었으니, 내가 원하는 방향으로 바꿔볼 것이다.

원하는 화면은 다음과 같다.

닉네임과 설명을 추가해볼 것이다.
그 전에, 만든 키보드를 스캔을 구현해둔 파일로 옮겨보자!

return Expanded(
        child: Center(
          child : Column(
            mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                CircleAvatar(
                  // 여기에 프로필 이미지 설정
                  radius: 50, // 이미지 크기 설정
                  backgroundImage: NetworkImage(avatar), // 네트워크 이미지 사용 예시
                ),
                SizedBox(height: 30,
                ),
                Text(
                  printNickname,
                  style: nameTextStyle,
                ),
                Text(
                  "얼마 만큼의 매듭을 보낼까요?",
                  style: TextStyle(
                    fontSize: 25.0,
                    color: Colors.orange,
                  ),
                ),
                SizedBox(height: 30,
                ),
                Text(
                  display,
                  style: textStyle,
                ),
                Text(
                    "잔액 : 10,000매듭" //api 값 가져오기
                )
              ]
          ),
        )
    );

우선 api를 통해 받아온 프로필 사진과 닉네임이 표시되도록 하였다.(현재는 test api에서 받아왔기 때문에 닉네임이 아니라 first name이지만...)
그리고 정렬울 왼쪽 정렬로 변경하였다

결과

가진 잔액을 같이 받아와서, 잔액 이상 입력이 되면 입력을 못하게끔 하는 기능도 구현을 해야 하는데, api 데이터를 받아 오는 방법 자체는 아니까 데이터가 마련되면 기능을 추가하려고 한다.

profile
코린이

0개의 댓글

관련 채용 정보