Tempo Di Valse

[Flutter/적응기] OS에 따른 Dialog 를 뿌려주기 본문

개발/Flutter

[Flutter/적응기] OS에 따른 Dialog 를 뿌려주기

TempoDiValse 2022. 3. 2. 11:32

 

각 OS 에서는 다른 형태의 다이얼로그 레이아웃을 가지고 있는데 안드로이드에서는 Material 디자인에 맞는 레이아웃을, iOS 에서는 Cupertino 디자인에 맞는 레이아웃을 가지고 있고 그 모습은 우리가 아는 그 모습이 맞다.

 

Flutter 의 다이얼로그는 어떻게 만들까 확인하고자 하나를 만들어 봤는데, Builder 하는 부분부터 Android 나 iOS 에서 만드는 방식과는 너무 달라서 복잡해보이는 부분이 조금 있었다. 게다가 다이얼로그를 통합으로 관리하는 기능이 있겠지 하며 찾아봤는데 웬걸.. Android 용 iOS 용 서로 다른 메소드를 통해서 개발을 해야 하는 것 같았다.

 

물론 Android 나 iOS 디자인 중 하나를 선택해서 개발하는 것이 편할 수도 있겠지만 요구사항은 항상 그렇지가 않기 때문에 하나의 메소드에서 플랫폼 별 UI를 대응하는 방식으로 짜보았다. 


1. Dialog 관리 클래스 만들기

 

 몫 좋은 위치에 dialog.dart 파일을 만들었다. 이 파일 안에서 alert 와 confirm 형태의 다이얼로그를 관리할 예정이다. 개발에 들어가기 이전에 공통 메소드로 호출을 할 것이기 때문에 머릿속에서 다이얼로그에서 제일 필요로 하는 것들이 어떤 것들이 있는가에 대해 생각해보도록한다. 그렇게 나온 속성들은,

1. 다이얼로그 타이틀
2. 다이얼로그 메세지
3. Positive Event
4. (Optional) Negative Event

이 4가지 였다. 여기서 4번째 항목을 Optional 이라고 정한 것은, confirm 형은 보통 2개(확인/취소) 의 버튼을 가지고 있지만 alert 형은 1개의 버튼 만을 다뤄서 있을 수도, 없을 수도 있는 Nullable 한 값이기 때문에 Optional 으로 지정을 한 것이다.

 

그럼 Dart 파일에 해당되는 속성에 맞도록 클래스와 메소드를 작성해보자.

// dialog.dart

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

class UDialog {
    static void confirm(BuildContext context, {
    	required String title,
        required String content,
        required String positiveText,
        required void Function() onPositive,
        String negativeText,
        void Function() onNegative,
    }){
    	// Not Implemented.
    }
}

코드블럭을 Swift로 했더니 required 단어까지 인식해서 좋네

일단 대표 메소드를 작성하긴 했는데, Positive 와 Negative 를 받는 부분이 너무 지저분한 것 같다. 굳이 같은 파트를 다루는데 하나는 텍스트, 하나는 이벤트처리 파라미터로 각각 받아야 되나 싶다. 그래서 이쁜 코딩을 위해 조금 바꿔보도록 한다. 

// dialog.dart

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

class UDialog {
    static void confirm(BuildContext context, {
    	required String title,
        required String content,
        required DialogAction positive,
        DialogAction? negative,
    }){
    	// Not Implemented.
    }
}

class DialogAction {
    String text;
    OnPressed? event;
    
    DialogAction(this.text, this.event);
    
    void _onDismiss(BuildContext context) { Navigator.pop(context); }
    
    VoidFunction _invokeOnFunction(BuildContext context) => (){
        if(event != null && !event!()) return;
    	
        _onDismiss(context);
    }
}

typedef OnPressed = bool Function();
typedef VoidFunction = void Function();

 

아까보다 파라미터 관리하기도 쉽고, 추가적으로 이벤트를 선택할 때에 Alert 가 Dismiss 될 수 있도록 로직을 추가하였다. Navigator 의 pop() 메소드를 사용하면 Alert 까지 삭제 해주는 것 같다. 그리고 typedef 를 통해서 보기 힘든 Function 리턴 타입을 재정의 해보았다. Flutter 에서는 Function 을 리턴 타입으로 넘겨줄 때에 Function 을 초기화 하는 모습처럼 넘겨주는 거 같은데 그리 친숙하지 않아보여서typedef 을 사용해 보았다. 이렇게 하나 써 보는거지..

 

DialogAction 의 역할은 대략 이런 것이다. 버튼의 텍스트와 이벤트는 받는데, 그 중 이벤트는 bool 값을 던져주어야 하며 T 이면 해당 팝업을 이벤트를 발생하고서 Dismiss 를 시켜주고, F 이면 Dismiss 를 시켜주지 않는 그런 역할을 하도록 만들었다. 여기서 더 발전하게 된다면 옵션값을 통해서 리턴값에 상관없이 Dismiss 를 시켜주도록 만들 수 있을 것이다.

 

그럼 이제 다이얼로그 UI 를 그려보도록 하자.

 

2. 다이얼로그 UI 를 만들기

 

Android 에서는 AlertDialog.Builder 로, iOS 는 오래전에 했지만 UIAlertController 와 UIAlertAction 의 조합으로 만들었었는데 Flutter 에서는 showDialog 와 showCupertinoDialog 를 통해 각각 Android 와 iOS 의 디자인을 가진 Alert UI 를 만들 수 있었다.

 

각각 Dialog 의 사용법을 확인해보면,

// Material UI
showDialog(context: BuildContext,
    builder: (BuildContext context) => 
        AlertDialog(
            title: Text(String),
            content: Text(String),
            actions: [
            	...
            ],
        )
);

// Cupertino UI
showCupertinoDialog(context: BuildContext,
    builder: (BuildContext context) =>
        CupertinoAlertDialog(
            title: Text(String),
            content: Text(String),
            actions: [
                ...
            ],
        )
);

Android 와 iOS 에서 사용하는 정형화된 Alert 과는 다르게 title 과 content 가 String 만을 받는 것이 아니라 Widget 단위로 데이터를 받는 점을 알 수 있다. 이렇게 메소드를 뚫어 UI 를 커스터마이즈하기 쉽게 만들어 놓았다. 그리고 저 3개의 파라미터 이외에 다른 파라미터도 각각이 가지고 있지만 제일 중요한 것은 다음의 파라미터들이 공통적으로 들어간다는 것이다.

1. title
2. content
3. actions

그래서 다시 클래스에 메소드를 각각 정의해보면,

// dialog.dart

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

class UDialog {
    static void confirm(BuildContext context, {
    	required String title,
        required String content,
        required DialogAction positive,
        DialogAction? negative,
    }){
    	// Not Implemented.
    }
    
    // Material UI
    static void _showMaterialStyle(BuildContext context, {
        required String title,
        required String content,
        required DialogAction positive,
        DialogAction? negative,
    }){
        showDialog(context: BuildContext,
            builder: (BuildContext context) => 
                AlertDialog(
                    title: Text(String),
                    content: Text(String),
                    actions: [ ... ],
                )
        );
    }
    
    // Cupertino UI
    static void _showCupertinoStyle(BuildContext context, {
        required String title,
        required String content,
        required DialogAction positive,
        DialogAction? negative,
    }){
        showCupertinoDialog(context: BuildContext,
            builder: (BuildContext context) =>
                CupertinoAlertDialog(
                    title: Text(String),
                    content: Text(String),
                    actions: [
                        ...
                    ],
                )
        );    
    }
}

으로 일단 정리 해놓을 수 있다. 하지만 'actions' 에 대한 값은 아직 정의하지 않았다. 

 

Action 은 Android 와 iOS 의 정의가 조금 다른데, 여기는 iOS 쪽에서 UIAlertAction 넣는 부분과 비슷한 느낌을 가지고 있는 것 같다. iOS 에서는 UIAlertController 에 UIAlertAction 이라는 객체를 하나씩 정의한 다음 Array 형식으로 추가를 시키는데, Flutter 에서는 Android 는 TextButton, iOS 는 CupertinoDialogAction 이라는 객체를 통해서 Alert 에 들어가는 버튼을 추가시켜주는 것 같다.

 

그래서 각각을 만들어 본다면,

// Material UI
TextButton(onPressed: Function, child: Text(String));

// Cupertino UI
CupertinoDialogAction(child: Text(String), 
    onPressed: Function, 
    isDefaultAction: Boolean, 
    isDestructiveAction: Boolean);

이런 방식으로 이루어진다. 둘의 공통점은 버튼의 텍스트를 받는 곳도 Widget 형식으로 넘겨받을 수 있고, 눌렀을 때의 이벤트를 호출하는 onPressed 를 받고 있다는 것이다. CupertinoDialogAction 은 isDefaultAction, isDestructiveAction 이란 부가적인 옵션도 가지고 있다.

 

이것들을 positive 와 negative 를 표현하기 위한 소스로 구성해 본다면,

// dialog.dart

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

class UDialog {
    static void confirm(BuildContext context, {
    	required String title,
        required String content,
        required DialogAction positive,
        DialogAction? negative,
    }){
    	// Not Implemented.
    }
    
    // Material UI
    static void _showMaterialStyle(BuildContext context, {
        required String title,
        required String content,
        required DialogAction positive,
        DialogAction? negative,
    }){
        showDialog(context: BuildContext,
            builder: (BuildContext context) => 
                AlertDialog(
                    title: Text(String),
                    content: Text(String),
                    actions: [ 
                        TextButton(child: Text(positive.text), onPressed: positive.event),
                        if(negative != null) TextButton(child: Text(negative.text), onPressed: negative.event),
                    ],
                )
        );
    }
    
    // Cupertino UI
    static void _showCupertinoStyle(BuildContext context, {
        required String title,
        required String content,
        required DialogAction positive,
        DialogAction? negative,
    }){
        showCupertinoDialog(context: BuildContext,
            builder: (BuildContext context) =>
                CupertinoAlertDialog(
                    title: Text(String),
                    content: Text(String),
                    actions: [
                        CupertinoDialogAction(child: Text(positive.text), onPressed: positive.event, isDefaultAction: true),
                        if(negative != null) CupertinoDialogAction(child: Text(negative.text), onPressed: negative.event, isDestructiveAction: true),
                    ],
                )
        );    
    }
}

Action 에 이런식으로 작성을 할 수 있을 것이다. 그런데 여기서 2가지를 고민해 보아야 할 것이 있다.

1. 굳이 TextButton/CupertinoDialogAction 을 하나씩 써줘야 하는가.
2. DialogAction 에서 정의한 onDismiss 는 어디로???

text 와 event가 속성에 있지만, 점(.)만 찍고 접근하는 거면 Data 클래스의 역할 밖에 되지 않지 괜히 클래스를 만든거기 때문에 DialogAction 클래스의 의미가 사라지게 될 것이다. 그래서 DialogAction 에서 각 OS 에 맞는 Action 을 만들어주는 메소드를 따로 만들어주도록 한다.

class DialogAction {
    String text;
    OnPressed? event;
    
    DialogAction(this.text, this.event);
    
    void _onDismiss(BuildContext context) { Navigator.pop(context); }
    
    VoidFunction _invokeOnFunction(BuildContext context) => (){
        if(event != null && !event!()) return;
    	
        _onDismiss(context);
    }
    
    TextButton createActionMaterial(BuildContext context) => 
        TextButton(onPressed: _invokeOnFunction(context), child: Text(text));
        
    CupertinoDialogAction createActionCupertino(BuildContext context, {
        bool isDefaultAction = false,
        bool isDestructiveAction = false
    }) => CupertinoDialogAction(child: Text(text), 
        onPressed: _invokeOnFunction(context),
        isDefaultAction: isDefaultAction,
        isDestructiveAction: isDestructiveAction,);
}

typedef OnPressed = bool Function();
typedef VoidFunction = void Function();

이제 외부에서는 createActionMaterial 과 createActionCupertino 만 호출하게되면 해당되는 Action 객체와 Dismiss 이벤트가 자동으로 탑재된다. Dialog 클래스의 내용도 바꿔보자.

// dialog.dart

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

class UDialog {
    static void confirm(BuildContext context, {
    	required String title,
        required String content,
        required DialogAction positive,
        DialogAction? negative,
    }){
    	// Not Implemented.
    }
    
    // Material UI
    static void _showMaterialStyle(BuildContext context, {
        required String title,
        required String content,
        required DialogAction positive,
        DialogAction? negative,
    }){
        showDialog(context: BuildContext,
            builder: (BuildContext context) => 
                AlertDialog(
                    title: Text(String),
                    content: Text(String),
                    actions: [ 
                        positive.createActionMaterial(context),
                        if(negative != null) negative.createActionMaterial(context),
                    ],
                )
        );
    }
    
    // Cupertino UI
    static void _showCupertinoStyle(BuildContext context, {
        required String title,
        required String content,
        required DialogAction positive,
        DialogAction? negative,
    }){
        showCupertinoDialog(context: BuildContext,
            builder: (BuildContext context) =>
                CupertinoAlertDialog(
                    title: Text(String),
                    content: Text(String),
                    actions: [
                        positive.createActionCupertino(context, isDefaultAction: true),
                        if(negative != null) negative.createActionCupertino(context, isDestructiveAction: true),
                    ],
                )
        );    
    }
}

  Actions 의 부분이 참 깔끔해졌다. 여기서 Cupertino 의 isDefaultAction 과 isDestructiveAction 을 간단하게 설명해본다면,

1. isDefaultAction : true 로 넘겨주면 버튼이 파란색 스타일을 가지게 된다. Postive 형식일 때 사용한다.
2. isDestructiveAction : true 로 넘겨주면 버튼이 빨간색 스타일을 가지게 된다. Negative 형식일 때 사용한다.

둘의 기능적인 차이는 딱히 없고, 스타일을 바꿔주는 상징적인 플래그 라고 생각하면 접근하기가 조금 쉬울 거라 생각한다.

 

이렇게 UI 는 그려보았고, 다음에는 분기처리하여 보여주는 것을 만들어보자.

 

3. 분기 처리

 

이제 confirm 에 들어갈 내용을 다듬어야 할 차례이다. 지금까지 각각의 UI 를 만들었지만, 정작 호출해야될 메소드인 confirm 에는 아무것도 작성하지 않았기 때문에 작동을 할 수 없는 상태이다.

 

그래서 confirm 에서는 간단하게 분기처리하여 보여줄 코드만 작성하도록 한다.

// dialog.dart

class UDialog {
    static void confirm(BuildContext context, {
      required String title,
      required String content,
      required DialogAction positive,
      DialogAction? negative,
    }){
      switch(Theme.of(context).platform){
        case TargetPlatform.iOS: 
            _showCupertinoStyle(context, 
                title: title, 
                content: content, 
                positive: positive, 
                negative: negative); 
            break;
        default: 
            _showMaterialStyle(context, 
                title: title, 
                content: content, 
                positive: positive, 
                negative: negative); 
            break;
      }
    }
    
    ...Codes
}

Theme 의 platform 을 통해 해당 디바이스의 OS 를 가지고 올 수 있다. 그래서 OS 별로 알맞는 Alert 를 뿌려줄 수 있도록 메소드를 호출하면,

 

완성!!!

 

 


전체 소스 복사 할 생각말고 열심히 원리를 공부해 보자.

// dialog.dart

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

class UDialog {
    static void confirm(BuildContext context, {
      required String title,
      required String content,
      required DialogAction positive,
      DialogAction? negative,
    }){
      switch(Theme.of(context).platform){
        case TargetPlatform.iOS: _showCupertinoStyle(context, title: title, content: content, positive: positive, negative: negative); break;
        default: _showMaterialStyle(context, title: title, content: content, positive: positive, negative: negative); break;
      }
    }

    static void _showMaterialStyle(BuildContext context, {
      required String title,
      required String content,
      required DialogAction positive,
      DialogAction? negative,
    }){
      showDialog(context: context,
          builder: (BuildContext context) =>
              AlertDialog(
                title: Text(title),
                content: Text(content),
                actions: [
                  positive.createActionMaterial(context),
                  if(negative != null) negative.createActionMaterial(context)
                ],
              )
          );
    }

    static void _showCupertinoStyle(BuildContext context,{
      required String title,
      required String content,
      required DialogAction positive,
      DialogAction? negative,
    }){
      showCupertinoDialog(context: context,
          builder: (BuildContext context) =>
              CupertinoAlertDialog(
                title: Text(title),
                content: Text(content),
                actions: [
                  positive.createActionCupertino(context, isDefaultAction: true),
                  if(negative != null) negative.createActionCupertino(context, isDestructiveAction: true)
                ],
              )
      );
    }
}


class DialogAction {
  String text;
  OnPressed? event;

  DialogAction(this.text, this.event);

  void _onDismiss(BuildContext context){ Navigator.pop(context); }

  VoidFunction _invokeOnFunction(BuildContext context) => (){
    if(event != null && !event!()) return;

    _onDismiss(context);
  };

  TextButton createActionMaterial(BuildContext context) => TextButton(onPressed: _invokeOnFunction(context), child: Text(text));
  CupertinoDialogAction createActionCupertino(BuildContext context, {
    bool isDefaultAction = false,
    bool isDestructiveAction = false
  }) => CupertinoDialogAction(child: Text(text), onPressed: _invokeOnFunction(context), isDefaultAction: isDefaultAction, isDestructiveAction: isDestructiveAction,);
}

typedef OnPressed = bool Function();
typedef VoidFunction = void Function();

 

반응형
Comments