달력

12

« 2024/12 »

  • 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
  • 31
반응형

일전에 이미지를 특정 영역에서 크기와 위치가 변경될 수 있는 기능을 만들어야 하는 일이 있었다.
당시 pub.dev에서 해당 기능이 구현되어 있는 패키지를 찾아보았지만 딱 맞는 것이 없었다.
 
결국 제스처로 크기(scale)와 x축 y축을 통해 위치를 자유롭게 조정할 수 있는 matrix_gesture_detector를 발견해서
아래 소스를 작성하여 원하는 기능을 구현할 수 있었다.
 
1. yaml을 작성한다.

path_provider: 2.0.1
matrix_gesture_detector: 0.1.0

 
2. moveImage.dart를 작성한다.

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:matrix_gesture_detector/matrix_gesture_detector.dart';
import 'package:path_provider/path_provider.dart';

class moveImage extends StatefulWidget {
  String path;
  String fileType;
  double width;
  double height;

  @override
  _moveImageState createState() => _moveImageState();

  moveImage({
    @required this.path,
    this.fileType = "storage",
    this.width = 450,
    this.height = 250,
    Key key})
      : super(key: key);
}

class _moveImageState extends State<moveImage> {
  Map<String, dynamic> item = {};
  
  @override
  void initState() {
    super.initState();
    setImageFileInfo();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
        width: widget.width,
        height: widget.height,
        child: moveImage(this.item)
    );
  }

  Widget moveImage(item) {
    return new FutureBuilder<File>(
        future: item["fileType"] == "assets" ? getImageFileFromAssets(item["path"]) : getFile(item["path"]),
        builder: (context, snapshot) {
          if (snapshot.data == null || item["matrix"] == null) {
            return Container();
          } else {
            return MatrixGestureDetector(
                shouldRotate: false,
                shouldScale: true,
                onMatrixUpdate: (m, tm, sm, rm) {
                  setState(() {
                    var maxWidth = widget.width;
                    var maxHeight = widget.height;
                    var width = item["width"];
                    var height = item["height"];

                    Matrix4 matrix = Matrix4.identity();
                    matrix = MatrixGestureDetector.compose(item["matrix"], tm, sm, rm);
                    Offset translation = MatrixGestureDetector.decomposeToValues(matrix).translation;
                    Size changeSize = MatrixUtils.transformRect(matrix, item["transformKey"].currentContext.findRenderObject().paintBounds).size;

                    var initialScale = item["initialScale"];
                    var changeWidth = changeSize.width;
                    var changeHeight = changeSize.height;

                    var chkRatio = changeHeight/maxHeight;

                    if (initialScale / 2.0 <= chkRatio && chkRatio <= initialScale + 2.0) {
                      Offset currentTranslation = MatrixGestureDetector.decomposeToValues(item["matrix"]).translation;

                      var fullHeight = height * maxWidth / width;
                      var fullChangeWidth = width * changeHeight / height;
                      var fullChangeHeight = height * changeWidth / width;
                      if (fullHeight >= maxHeight) {
                        if (translation.dy > 0 || translation.dx > 0
                            || changeHeight + translation.dy < maxHeight || fullChangeWidth + translation.dx < maxWidth) {
                          var dy = translation.dy, dx = translation.dx;

                          if (changeHeight >= maxHeight) {
                            if (translation.dy > 0) {
                              dy = 0.0;
                            }
                            if (changeHeight + translation.dy < maxHeight) {
                              if (changeHeight > maxHeight) {
                                dy = maxHeight - changeHeight;
                              } else {
                                dy = currentTranslation.dy;
                              }
                            }
                          } else {
                            if (maxHeight < changeHeight + translation.dy) {
                              dy = maxHeight - changeHeight;
                            } else if (0.0 > currentTranslation.dy + translation.dy) {
                              dy = 0.0;
                            } else {
                              dy = translation.dy;
                            }
                          }

                          if (fullChangeWidth >= maxWidth) {
                            if (translation.dx > 0){
                              dx = 0.0;
                            }
                            if (fullChangeWidth + translation.dx < maxWidth) {
                              if (fullChangeWidth > maxWidth) {
                                dx = maxWidth - fullChangeWidth;
                              } else {
                                dx = currentTranslation.dx;
                              }
                            }
                          } else {
                            if (maxWidth < fullChangeWidth + translation.dx) {
                              dx = maxWidth - fullChangeWidth;
                            } else if (0.0 > currentTranslation.dx + translation.dx) {
                              dx = 0.0;
                            } else {
                              dx = translation.dx;
                            }
                          }

                          tm = translateMatrix(new Offset(dx - currentTranslation.dx, dy - currentTranslation.dy));
                          matrix = MatrixGestureDetector.compose(item["matrix"], tm, sm, rm);
                        }
                      } else {
                        if (translation.dy > 0 || translation.dx > 0
                            || fullChangeHeight + translation.dy < maxHeight || changeWidth + translation.dx < maxWidth) {
                          var dy = translation.dy, dx = translation.dx;

                          if (fullChangeHeight >= maxHeight) {
                            if (translation.dy > 0) {
                              dy = 0.0;
                            }
                            if (fullChangeHeight + translation.dy < maxHeight) {
                              if (fullChangeHeight > maxHeight) {
                                dy = maxHeight - fullChangeHeight;
                              } else {
                                dy = currentTranslation.dy;
                              }
                            }
                          } else {
                            if (maxHeight < fullChangeHeight + translation.dy) {
                              dy = maxHeight - fullChangeHeight;
                            } else if (0.0 > currentTranslation.dy + translation.dy) {
                              dy = 0.0;
                            } else {
                              dy = translation.dy;
                            }
                          }

                          if (changeWidth >= maxWidth) {
                            if (translation.dx > 0){
                              dx = 0.0;
                            }
                            if (changeWidth + translation.dx < maxWidth) {
                              if (changeWidth > maxWidth) {
                                dx = maxWidth - changeWidth;
                              } else {
                                dx = currentTranslation.dx;
                              }
                            }
                          } else {
                            if (maxWidth < changeWidth + translation.dx) {
                              dx = maxWidth - changeWidth;
                            } else if (0.0 > currentTranslation.dx + translation.dx) {
                              dx = 0.0;
                            } else {
                              dx = translation.dx;
                            }
                          }

                          tm = translateMatrix(new Offset(dx - currentTranslation.dx, dy - currentTranslation.dy));
                          matrix = MatrixGestureDetector.compose(item["matrix"], tm, sm, rm);
                        }
                      }
                      item["matrix"] = matrix;
                    }
                  });
                }, child: new Transform( key: item["transformKey"], transform: item["matrix"],
                child: new Image.file(snapshot.data, fit:BoxFit.contain, alignment: Alignment.topLeft,)
              )
            );
          }
        });
  }

  Matrix4 translateMatrix(Offset translation) {
    var dx = translation.dx;
    var dy = translation.dy;

    return Matrix4(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, dx, dy, 0, 1);
  }

  Future setImageFileInfo() async {
    this.item["path"] = widget.path;
    this.item["fileType"] = widget.fileType;

    if (this.item["transformKey"] == null) {
      this.item["transformKey"] = new GlobalKey();
    }

    File image;
    if (this.item["fileType"] == "assets") {
      image = await getImageFileFromAssets(this.item["path"]);
    } else {
      image = new File(this.item["path"]);
    }
    var decodedImage = await decodeImageFromList(image.readAsBytesSync());
    this.item["width"] = decodedImage.width.toDouble();
    this.item["height"] = decodedImage.height.toDouble();

    var maxWidth = widget.width;
    var maxHeight = widget.height;
    var width = this.item["width"];
    var height = this.item["height"];

    var widthRatio = (maxWidth/width);
    var heightRatio = (maxHeight/height);
    var initialScale = (heightRatio/widthRatio);

    var fullHeight = height * maxWidth / width;
    if (fullHeight >= maxHeight) {
      initialScale = (widthRatio/heightRatio);
    }

    setState(() {
      this.item["initialScale"] = initialScale;
      this.item["matrix"] = Matrix4.identity().scaled(initialScale);
    });
  }
}

Future<File> getImageFileFromAssets(String path) async {
  final byteData = await rootBundle.load('assets/$path');

  String tmpPath = '${(await getTemporaryDirectory()).path}/$path';

  final file = File(tmpPath);
  await file.create(recursive: true);
  await file.writeAsBytes(byteData.buffer.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes));

  return file;
}

Future<File> getFile(String path) async {
  return File(path);
}

 
3. 작성한 moveImage.dart를 widget으로 사용할 화면의 dart 소스에서 불러온다.

moveImage(
  path: "test.jpeg",
  fileType: "assets",
),

 
위 예시는 assets에 있는 이미지 기준으로 작성되어 있으며
휴대폰에 있는 사진첩에서 이미지를 가져온다면(권한 관련 처리도 필수!)
fileType를 없애고 path에 이미지의 실제 경로(사진 선택 기능을 통해 알게 된 경로)를 넣어주면 된다.
 
 

 
 
참고 : https://pub.dev/packages/matrix_gesture_detector

반응형
:
Posted by 리샤씨
반응형

최근 Flutter TextField에서 숫자 입력 시 천 단위 마다 콤마를 표시되도록 해야하는 일이 생겼는데

이게 생각보다 간단하지 않았다. 

일단 숫자 입력만 되도록 제한하고 천 단위 마다 콤마 표시하는 것 자체는 크게 어렵지 않았는데,

콤마를 표시하면 그것도 자리 차지를 하게 되어 중간에서 입력/삭제 후 자연스러운 커서 위치를 잡기가 어려웠지만

규칙을 찾아 수식을 작성해서 해결했다.

 

완성된 코드는 다음과 같다.

import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';

class ThousandCommaTextField extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Color(0xffffffff),
      body: SafeArea(
        child: Container(
          padding: EdgeInsets.all(10),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              TextField(
                keyboardType: TextInputType.number,
                textAlign: TextAlign.right,
                inputFormatters: <TextInputFormatter>[
                  FilteringTextInputFormatter.allow(RegExp(r'[0-9]')),
                  FilteringTextInputFormatter.digitsOnly,
                  ThousandCommaInputFormatter()
                ],
              )
            ],
          )
        )
      ),
    );
  }
}

String thousandComma(dynamic val, {String defaultVal = ""}) {
  NumberFormat numberFormat = NumberFormat('#,###', "ko_KR");
  if (val != null && val != "") {
    if (val is int || val is double) {
      return numberFormat.format(val);
    } else if (double.tryParse(val) != null) {
      return numberFormat.format(double.parse(val));
    } else if (int.tryParse(val) != null) {
      return numberFormat.format(int.parse(val));
    }
  }

  return defaultVal;
}

class ThousandCommaInputFormatter extends TextInputFormatter {
  TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
    String oldNum = oldValue.text.replaceAll(",", "");
    String newNum = newValue.text.replaceAll(",", "");

    String newText = thousandComma(newValue.text, defaultVal: "0");

    // 다음 커서 위치 잡기
    int selectionIndex = ((newValue.text.length -1) / 3).floor() // 전체 ,(comma)의 수
                        - (((newValue.text.length - newValue.selection.end)-1) / 3).floor() //현재 커서 위치를 기준으로 오른쪽에 있는 ,(comma)의 수
                        + newValue.selection.end;

    if (selectionIndex > newText.length) {
      selectionIndex = newText.length;
    }

    if (oldNum == newNum) {//,(comma) 뒤에 커서놓고 삭제 시
      //1,234,567에서 4,뒤(커서 위치 6)에서 삭제하면 123,567이 되므로 1뒤의 ,와 4가 같이 없어져서 커서 위치가 2만큼 왼쪽으로 이동해야 한다. (최종 커서 위치 4가 되야함)
      //98,765에서 8,뒤(커서 위치 3)에서 삭제하면 9,765가 되므로 8만 없어져서 커서 위치가 1만큼 왼쪽으로 이동해야 한다. (최종 커서 위치 2가 되야함)
      selectionIndex = selectionIndex - (newText.replaceAll(",", "").length % 3 == 1 ? 2 : 1);
      newText = newValue.text.substring(0, newValue.selection.end - 1) + newValue.text.substring(newValue.selection.end);
      newText = thousandComma(newText, defaultVal: "0");
    }

    return newValue.copyWith(
        text: newText,
        selection: TextSelection.collapsed(
            offset: selectionIndex));
  }
}

 

 

참고 출처:

https://stackoverflow.com/questions/49577781/how-to-create-number-input-field-in-flutter

https://stackoverflow.com/questions/61866207/thousand-separator-without-decimal-separator-on-textformfield-flutter

반응형
:
Posted by 리샤씨
반응형

가끔가다, 파일 첨부 시 첨부된 파일의 용량을 보여줘야 할 때가 있다.

보통 파일 첨부 시 알 수 있는 것은 로우한 파일 크기인데,

필요한 건 해당 값을 1024로 나눈 값인 KB/MB/GB 등등이다.

 

처음엔 JS에서만 사용했었는데 최근 flutter에서도 필요하게 되어 두 가지 버전을 공유한다.

 

javascript 버전

function spaceFormat(spaceValue, decimalPoint){
	var fileSizeNmList = ["Byte", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
	var fileSizeNmIndex = 0;
	
	while (spaceValue > 1024 && fileSizeNmIndex < fileSizeNmList.length) {
		spaceValue /= 1024;
		fileSizeNmIndex++;
	}
	
	if (spaceValue.toFixed != undefined) {
		spaceValue = spaceValue.toFixed(decimalPoint);
	}
	return spaceValue + fileSizeNmList[fileSizeNmIndex];
}

 

flutter 버전

String spaceFormat(double spaceValue, int decimalPoint){
  List<String> fileSizeNmList = ["Byte", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
  int fileSizeNmIndex = 0;

  while (spaceValue > 1024 && fileSizeNmIndex < fileSizeNmList.length) {
    spaceValue /= 1024;
    fileSizeNmIndex++;
  }

  return spaceValue.toStringAsFixed(decimalPoint) + fileSizeNmList[fileSizeNmIndex];
}

 

반응형
:
Posted by 리샤씨
2022. 8. 3. 11:22

Flutter에서 Flick 화면 만들기 개발/Flutter2022. 8. 3. 11:22

반응형

일전에 Flick(한 방향으로 미는 것을 말한다고 한다. 참고 : https://thankee.tistory.com/117) 동작을 하는 화면을 요청 받은 적이 있었다.

그때는 한 방향으로 밀면 화면이 밀은 그대로 높이를 이동시키는 화면이었는데

최근 또 다른 프로젝트를 하는데 비슷한 동작을 하는 화면이 있어서

일전에 만든 소스를 활용해서 좀 더 범용성 있게 작성해보았다.

 

이번에 필요한 화면은 똑같은 동작으로 화면의 높이를 변화시키지만,

고정적인 세 가지 위치(최저/중간/최대)로만 이동이 가능한 기능과

최대 크기(화면 전체였다)로 키웠을 때 그림자가 사라지게 하는 기능이 필요해서 그 부분만 추가로 작업해주었다.

 

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

class flickFrameCommon extends StatefulWidget {
  var mode; //soft, hard
  bool enabled;

  double height;
  double minHeight;
  double maxHeight;
  double middleHeight;

  EdgeInsets padding;
  Function changeHeight;//높이 값이 변화할 때 마다 flickFrameCommon를 호출한 곳에서 높이를 돌려 받을 수 있음
  Widget child;

  flickFrameCommon({
    Key key,
    this.mode = "soft",
    this.enabled = true,
    this.height = 52.0,
    this.minHeight,
    this.maxHeight,
    this.middleHeight,
    this.padding,
    this.changeHeight,
    @required this.child,
  }) : super(key: key);

  @override
  _flickFrameCommonState createState() => _flickFrameCommonState();
}

class _flickFrameCommonState extends State<flickFrameCommon> {
  int shadowAlpha = 125;
  double prevHeight;
  double Sensitivity = 100; //감도

  @override
  void initState() {
    super.initState();
    prevHeight = widget.height;

    if (widget.minHeight == null) {
      if (30 < widget.height && widget.height < 118.7 ) {
        widget.minHeight = widget.height;
      } else {
        widget.minHeight = 118.7;
      }
    }

    if (widget.mode != "hard" && widget.mode != "soft") {
      throw ArgumentError("The mode is soft or hard");
    }

    if (widget.height < widget.minHeight) {
      throw ArgumentError("The height is greater than the minimum value");
    }

    if (widget.maxHeight == null) {
      if (333.7 < widget.height && widget.height <= Get.height) {
        widget.maxHeight = widget.height;
      } else {
        widget.maxHeight = 333.7;
      }
    }

    if (widget.height > widget.maxHeight) {
      throw ArgumentError("The height is less than the maximum value");
    }

    if (widget.padding == null) {
      widget.padding = EdgeInsets.only(left: 18.3, right: 18.3);
    }

    if (widget.mode == "hard") {
      if (widget.middleHeight == null) {
        throw ArgumentError("middleHeight is a must when in hard mode");
      }
      setHardHeight();
    }
  }

  @override
  Widget build(BuildContext context) {
    if (widget.height == widget.maxHeight) {
      shadowAlpha = 0;
    } else {
      shadowAlpha = 125;
    }

    return Positioned(
      bottom: 0,
      child: AnimatedContainer(
        width: Get.width,
        height: widget.height,
        duration: Duration(milliseconds: 500),
        padding: widget.padding,
        decoration: BoxDecoration(
          borderRadius: BorderRadius.only(topLeft: Radius.circular(24), topRight: Radius.circular(24)),
          boxShadow: [BoxShadow(
              color: Color.fromARGB(shadowAlpha, 0, 0, 0),
              offset: Offset(0,3),
              blurRadius: 5,
              spreadRadius: 0,
          )],
          color: const Color(0xffffffff),
        ),
        child: Listener(
            behavior: HitTestBehavior.opaque,
            onPointerMove: (details){
              if (widget.enabled) {
                setState(() {
                //이동된 거리(details.delta.dy)는 위쪽으로 이동하면 -로 나오므로 현재 높이에 -를 해주면 +가 되어 높이가 커짐
                  if (widget.height - details.delta.dy < widget.minHeight) {//이동된 지점(현재 높이 - 이동된 거리)이 최저 높이보다 낮으면
                    widget.height = widget.minHeight;//높이를 최저 높이로 변경
                  } else
                  if (widget.height - details.delta.dy > widget.maxHeight) {//이동된 지점(현재 높이 - 이동된 거리)이 최대 높이보다 높으면
                    widget.height = widget.maxHeight;//높이를 최대 높이로 변경
                  } else {//최대와 최저 중간값이면 
                    widget.height = widget.height - details.delta.dy;//높이를 이동된 지점으로 변경
                  }
                });
              }
            },
            onPointerDown: (details) {//Pointer 이벤트가 시작할 때
              if (widget.enabled) {
                prevHeight = widget.height;//이전 높이를 저장
              }
            },
            onPointerUp: (details) {//Pointer 이벤트가 끝날 때
              if (widget.enabled) {
              	if (widget.mode == "hard") {
                  setState(() {
                    setHardHeight();//hard mode이면 최종 높이를 최저/중간/최대로 지정
                  });
                }

                if (widget.changeHeight != null) {
                  widget.changeHeight(widget.height);
                }
              }
            },
            child: Column(children: [
            Expanded(child: widget.child)
          ]),
        ),
      ),
    );
  }

  setHardHeight () {
    var minH_d = (widget.height - widget.minHeight).abs();//이벤트가 끝난 지점과 최저 높이의 거리 계산
    var maxH_d = (widget.height - widget.maxHeight).abs();//이벤트가 끝난 지점과 최대 높이의 거리 계산
    var middleH_d = (widget.height - widget.middleHeight).abs();//이벤트가 끝난 지점과 중간 높이의 거리 계산

    var direction = widget.height - prevHeight;

    if (prevHeight == widget.minHeight) {//이전 높이가 최저 높이였을 때
      if (direction > Sensitivity) {//감도보다 위쪽으로 이동한 거리가 높으면
        if (maxH_d < middleH_d) {//최대 높이의 거리가 중간 높이 거리보다 가까우면
          widget.height = widget.maxHeight;//현재 높이를 최대 높이로 변경
        } else {
          widget.height = widget.middleHeight;//현재 높이를 중간 높이로 변경
        }
      } else {
        widget.height = widget.minHeight;//감도가 낮으면 원래 높이로 이동
      }
    } else if (prevHeight == widget.maxHeight) {//이전 높이가 최대 높이였을 때
      if (direction < -1 * Sensitivity) {//감도보다 아래쪽으로 이동한 거리가 높으면
        if (minH_d < middleH_d) {//최저 높이의 거리가 중간 높이 거리보다 가까우면
          widget.height = widget.minHeight;//현재 높이를 최저 높이로 변경
        } else {
          widget.height = widget.middleHeight;//현재 높이를 중간 높이로 변경
        }
      } else {
        widget.height = widget.maxHeight;//감도가 낮으면 원래 높이로 이동
      }
    } else {
      if (direction > Sensitivity) {//감도보다 위쪽으로 이동한 거리가 높으면
        widget.height = widget.maxHeight;//현재 높이를 최대 높이로 변경
      } else if (direction < -1 * Sensitivity) {//감도보다 아래쪽으로 이동한 거리가 높으면
        widget.height = widget.minHeight;//현재 높이를 최저 높이로 변경
      } else {//감도가 낮으면 원래 높이로 이동
        widget.height = widget.middleHeight;//감도가 낮으면 원래 높이로 이동
      }
    }
  }
}

 

크게 soft(기본값) / hard mode가 있는데 soft mode가 이전에 사용했던 최저/최대 높이 내에서 자유롭게 움직일 수 있는 방식이고

hard mode가 이번에 새로 개발한 최저/중간/최고 높이로 이동하는 방식이다.

 

get은 3.26.0 버전을 사용하였고

Get.height와 Get.wight 현재 디바이스의 높이/넓이를 말한다.

다른 방식으로 높이와 넓이를 구할 수 있으면 사용하지 않아도 된다.

 

ex) 

import 'dart:async';

import 'package:blog/widget/flick_frame_common.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';

class Test extends StatefulWidget {
  @override
  _TestState createState() => _TestState();
}

class _TestState extends State<Test> {
  double realMaxHeight = Get.height;
  double height;
  double minHeight = 52.0;
  double middleHeight = 296.0;

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

    height = minHeight;

    Future.delayed(Duration.zero, () async {
      setState(() {
        realMaxHeight = Get.height
            - MediaQuery.of(context).padding.top
            - MediaQuery.of(context).padding.bottom;
      });
    });
  }

  @override
  void dispose() {
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Color(0xffffffff),
      body: SafeArea(
        child: Container(
          width: Get.width,
          height: Get.height,
          child: Stack(children: [
            Container(
              width: Get.width,
              height: Get.height,
              alignment: Alignment.center,
              child: Text("Content Area"),
            ),
            flickFrameCommon(
              mode: "hard",
              height: height,
              padding: EdgeInsets.only(top: 20),
              minHeight: minHeight,
              middleHeight: middleHeight,
              maxHeight: realMaxHeight,
              changeHeight: (height) {
                setState(() {
                  this.height = height;
                });
              },
              child: Column(
                children: [
                  Text("Flick Frame Area")
                ],
              ),
            )
          ]),
        )
      ),
    );
  }
}

 

왼쪽 부터 순서대로 Filck Frame이 최저/중간/최고 높이일 때 화면

반응형
:
Posted by 리샤씨
2021. 8. 12. 20:53

Flutter에서 카카오톡 채널 추가하기 개발/Flutter2021. 8. 12. 20:53

반응형

Flutter를 한지 반년 이상 지났지만, 바쁘기도 했고 보통 pub.dev에서 갖다썼기 때문에

마땅히 포스팅할게 없어서 이제서야 관련 글을 작성합니다.

 

최근, 카카오톡 채널 추가하기 기능을 추가해달라는 요청을 받았는데 찾아보니 pub.dev에도 없고..

네이티브 소스를 좀 작성해야 가능한 것처럼 보여서 코틀린과 Swift로 작업을 진행했습니다.

카카오 개발 설정은 다른 블로그 글도 많기 때문에 생략했습니다.

설정하셔서 해시키도 등록해두시고 native app key를 얻으셔서 진행하시길 바랍니다.

 

그리고 제가 이해하기로는 카카오톡 친구를 강제적으로 추가해주는 API는 존재하지 않아보이고,

(가입시 카카오에서 제공하는 체크박스 제외)

카카오톡 친구를 보여주어 채널을 추가를 유도하는 정도의 기능만 구현할 수 있어보여서 그렇게 진행하였습니다.

 

(22. 9. 1) 1년이 지난 지금, 찾아보니 Kakao에서 Flutter를 지원해서 아래 링크를 따라하시면 간단히 할 수 있게 되었네요.

아마 kakao_flutter_sdk 버전 1.0.0이 공식 출시 되면서 22년 3월 경에 해당 링크와 기능을 지원하기 시작한거 같습니다.

낮은 확률로 다른 패키지와 충돌해서 kakao_flutter_sdk를 사용하실 수 없을 수도 있으니 

제가 작성한 소스도 남겨 놓도록 하겠습니다.

가능하시면 아래 링크를 통해 연결해보세요.

https://developers.kakao.com/docs/latest/ko/kakaotalk-channel/flutter

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

1. yaml을 작성한다.

flutter_kakao_login: 3.3.0
url_launcher: 6.0.3 # 카카오톡 설치 여부를 위해 추가

 

2. main.dart를 작성한다.

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_kakao_login/flutter_kakao_login.dart';
import 'package:url_launcher/url_launcher.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '카카오톡 채널 추가 Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: '카카오톡 채널 추가 Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  void goKakaoChannel() async {
    final installed = await canLaunch(Platform.isIOS ? "kakaokompassauth://authorize" : "kakaolink://");
    if (installed) {
      final FlutterKakaoLogin kakaoSignIn = new FlutterKakaoLogin();
      await kakaoSignIn.init("your_native_app_key");
      const MethodChannel _channel = const MethodChannel('myChannel');
      _channel.invokeMethod('addKakaoChannel', "your_channel_public_id");
    } else {
      //카카오톡이 설치되지 않았을때 처리
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            FloatingActionButton(
              onPressed: goKakaoChannel,
              tooltip: '카카오톡 채널 추가',
              child: Icon(Icons.add),
            )
          ],
        ),
      ),
    );
  }
}

 

3.  build.gradle(Project)에서 카카오 레파지토리 설정한다.

allprojects {
    repositories {
        google()
        jcenter()
        maven { url 'https://devrepo.kakao.com/nexus/content/groups/public/' }
    }
}

 

4. build.gradle(Module)에 디펜던시를 설정한다.

dependencies {
    implementation "com.kakao.sdk:v2-talk:2.4.2" // 친구, 메시지(카카오톡)
}

 

5. MainActivity.kt를 작성한다.

package com.risha.blog

import androidx.annotation.NonNull

import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugins.GeneratedPluginRegistrant
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

import com.kakao.sdk.talk.*
import com.kakao.sdk.common.util.*

class MainActivity: FlutterActivity() {

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        GeneratedPluginRegistrant.registerWith(flutterEngine)
        val channel = MethodChannel(flutterEngine.getDartExecutor(), "myChannel")
        channel.setMethodCallHandler(handler)
    }


    private val handler: MethodChannel.MethodCallHandler = MethodChannel.MethodCallHandler({ methodCall, result ->
        if (methodCall.method.equals("addKakaoChannel")) {
            val channelPublicId = methodCall.arguments as String
            val url = TalkApiClient.instance.channelChatUrl(channelPublicId)

            // CustomTabs 로 열기
            KakaoCustomTabsClient.openWithDefault(context, url)
        } else {
            result.notImplemented()
        }
    })
}

 

6. Podfile을 작성한다. 

# Uncomment this line to define a global platform for your project
# platform :ios, '11.0'

pod 'KakaoSDKTalk'  # 친구, 메시지(카카오톡)

작성 뒤, pod install합니다.

 

7. Info.plist를 작성한다.

    <key>LSApplicationQueriesSchemes</key>
    <array>
        <!-- common -->
        <string>kakao${your_native_app_key}</string>

        <!-- KakaoTalk login -->
        <string>kakaokompassauth</string>
        <string>storykompassauth</string>
    </array>

 

8. AppDelegate.swift를 작성한다.

import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    var navigationController: UINavigationController!
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    linkNativeCode(controller: controller)
    GeneratedPluginRegistrant.register(with: self)
    
    self.navigationController = UINavigationController(rootViewController: controller)
    self.window.rootViewController = self.navigationController
    self.navigationController.setNavigationBarHidden(true, animated: false)
    self.window.makeKeyAndVisible()
    
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

    private func addKakaoChannel(call: FlutterMethodCall, result: @escaping FlutterResult) {
        let vc = UIStoryboard.init(name: "Main", bundle: .main)
                                .instantiateViewController(withIdentifier: "ViewController") as! ViewController
                        if let arguments = call.arguments as? String {
                            vc.arguments = arguments
                        }
        
                        vc.result = result
                        self.navigationController.pushViewController(vc, animated: true)
    }
}

extension AppDelegate {
    
    func linkNativeCode(controller: FlutterViewController) {
        setupMethodChannel(controller: controller)
    }
    
    private func setupMethodChannel(controller: FlutterViewController) {
        let commonChannel = FlutterMethodChannel(name: "myChannel",
                                                  binaryMessenger: controller.binaryMessenger)
        
        commonChannel.setMethodCallHandler({
          (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
          // Note: this method is invoked on the UI thread.
            
            //let viewController = ViewController()
            if call.method == "addKakaoChannel" {
                self.addKakaoChannel(call: call, result: result)
            }
        })
    }
}

 

9. ViewController.swift를 작성한다.

import UIKit
import SafariServices
import KakaoSDKTalk

class ViewController: UIViewController, SFSafariViewControllerDelegate {
    var result: FlutterResult!
    var arguments: String!
    var safariViewController : SFSafariViewController? // to keep instance
    
    override func viewDidLoad() {
        super.viewDidLoad()

        self.safariViewController = SFSafariViewController(url: TalkApi.shared.makeUrlForChannelChat(channelPublicId: arguments)!)

        guard (self.safariViewController != nil) else { return }

        self.safariViewController?.modalTransitionStyle = .crossDissolve
        self.safariViewController?.modalPresentationStyle = .overCurrentContext
        self.safariViewController?.delegate = self
        self.present(self.safariViewController!, animated: true) {
            print("Kakao Talk Channel chat 연결 페이지 실행 성공")
        }
    }
    
    func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
        //To access the  Specific tabBar ViewController
        self.navigationController?.popViewController(animated: true)
    }
}

 

10. Storyboard ID를 작성한다.

AppDelegate.swift의 instantiateViewController(withIdentifier: "ViewController")는

Main.storyboard에 ViewController를 추가하신 뒤, Storyboard ID를 withIdentifier 값으로 기입하시면 됩니다.

 

 

 

 

 

참고 출처 :

https://blog.usejournal.com/integrating-native-third-party-sdk-in-flutter-8aab03afa9da

https://developers.kakao.com/docs/latest/ko/kakaotalk-channel/ios

 

반응형
:
Posted by 리샤씨


반응형
반응형