Things take time

[Flutter] Flutter안에 Native(iOS)의 UIView 넣는 방법 본문

Flutter

[Flutter] Flutter안에 Native(iOS)의 UIView 넣는 방법

겸손할 겸 2022. 5. 17. 09:12

[개요]

플러터의 장점은 각 OS별로 짜야하는 UI나 기능 작업들을 하나의 코드인 다트로 작성할 수 있다는 점이다. 다만, 특정한 기능 등은 각 네이티브에서 수행해야할 때가 있다. 

마치 하이브리드 앱처럼 웹뷰와 네이티브 사이간 interface를 구성하여 작성하는 것처럼, Flutter와 네이티브간에도 이런 방법이 가능하다. 대표적인게 MethodChannel을 이용한 전달이다. 이 방법은 비동기를 통한 콜백처리 까지 가능한데, 이 글에서는 MethodChannel이 아니라 뷰 객체(UIView)를 Flutter안에 넣고 싶을 때다.

 

공식 도큐먼트에서는 구글맵과 같은 뷰를 각 OS별로 API연동 및 작성하고, 각각의 뷰를 플러터안에 넣는 방법으로 소개되어있는데, 한글 문서로 된 것은 많이 없어서 내가 정리하려한다. 그리고 공식 문서에서 말하는 공식 단어 등은 솔직히 이해가 그리 잘 되지 않는 편이라서..

 

https://docs.flutter.dev/development/platform-integration/platform-views

 

Hosting native Android and iOS views in your Flutter app with Platform Views

Learn how to host native Android and iOS views in your Flutter app with Platform Views.

docs.flutter.dev

 

일단 공식문서 위치는 링크로 두었다.

 

추후에 MethodChannel이란 글을 쓰겠지만, 나중에 하고보면 둘의 사용 코드만 다를 뿐 비슷하단 느낌을 받았다.

[사용 방법]

In Flutter

아래의 패키지를 import한다.

import 'package:flutter/widgets.dart';
import 'package:flutter/services.dart';;
import 'package:flutter/foundation.dart';

그리고 해당 UIView가 들어갈 위젯쪽에 아래의 코드를 참고한다.

class _PracticeMainState extends State<PracticeMain> {
  static const String VIEW_TYPE = 'gyeom-type';

  // Pass parameters to the platform side.
  static const Map<String, dynamic> creationParams = <String, dynamic>{
    "param": "겸식이"
  };

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: PreferredSize(
          preferredSize: Size.fromHeight(0),
          child: AppBar(),
        ),
        body: SafeArea(
          child: _setPlatformView(),
        ),
      ),
    );
  }

  Widget _setPlatformView() {
    switch (defaultTargetPlatform) {
      case TargetPlatform.iOS:
        return Container(
          width: double.maxFinite,
          height: 300,
          child: UiKitView(
            viewType: VIEW_TYPE,
            layoutDirection: TextDirection.ltr,
            creationParams: creationParams,
            creationParamsCodec: const StandardMessageCodec(),
          ),
        );
      case TargetPlatform.android:
        return Container(
          child: Center(
            child: Text('Android'),
          ),
        );
      default:
        return Container();
    }
  }
}

 

코드를 간단히 설명하자면, 컬럼 방식으로 된 위젯이 있으며 이 위젯은 _getPlayerController라는 함수에서 리턴받은 위젯 하나를 가진 컬럼위젯이다. _getPlayerController에서는 UIKitView라는 위젯을 Child로 가진 컨테이너 위젯을 리턴한다.

 

VIEW_TYPE이란 변수안에 들어가는 값과, Map형(Swift의 Dictionary에 대응)에 대응하는 파라미터를 UIKitView라는 위젯의 생성자로 넣어주면 되는 것이다. 그래서 플러터에서 할 일은, Swift에서 맞춰줄 키 값과 파라미터들을 세팅해주는 것이다. 그리고 그걸 매치되는 Swift쪽에서 해당 값을 바탕으로 세팅하면 된다.

In iOS(AppDelegate)

import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    GeneratedPluginRegistrant.register(with: self)
      
      let factory = GyeomFactory()
      self.registrar(forPlugin: "GyeomPlugin")?.register(factory, withId: "gyeom-type")
      
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

Flutter로 앱을 만들면 기본으로 생성되는 AppDelegate내 소스다. FlutterAppDelegate를 상속받아 사용하는데, 여기서 눈여겨 볼 것은 GyeomFactory라는 클래스명과, widId 파라미터에 들어가는 저 값을 주목하자.

 

self.registrar라는 함수는 FlutterAppDelegate내 FlutterPlguinRegistry라는 프로토콜 내에 갖고 있는 함수인데, 해석 그대로 등록해주는 사람(registrar)에게 withId란 키 값을 가진 팩토리를 -> GyeomPlugin라는 이름으로 가진 플러그인 내에 넣고, 이 플러그인을 등록해 달라. 이런 의미로 해석할 수 있다.

 

여기서 플러그인이라는 것은 다트 기준 패키지의 한 종류이다. 여기서 다트로만 패키지를 만들었느냐, 위처럼 스위프트나 코틀린처럼 다양한 언어로 작성됐느냐에 따라 플러그인의 기준으로 나눈다고 한다. 현재 이 프로젝트를 패키지로 만든다거나 연동, 작성하지 않기 때문에 저 플러그인 값은 현재 예제에서는 아무런 값이 들어가도 상관없다. 이후 다트 패키지를 만들때 사용할것 같은데 지금 예제에서는 아무런 값을 입력하자.

In iOS(FLNativeViewFactory)

import Foundation
import Flutter


/**
 FlutterPlatformViewFactory를 상속받는 클래스 : FlutterPlatformView를 상속받아 UIView를 리턴하는 그 클래스를 연결하는 클래스(create()가 프로토콜내 필수 구현함수로 등록되어 있다, createArgsCodec()는 optional)
 */
class GyeomFactory: NSObject, FlutterPlatformViewFactory{
    private var gyeomView: GyeomView?
    private var messenger: FlutterBinaryMessenger?

    /**
     Only needs to be implemented if `createWithFrame` needs an arguments parameter.
     Dart에서 파라미터 전달시 필요
     */
    func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
        return FlutterStandardMessageCodec.sharedInstance()
    }
    
    override init(){
        super.init()
    }
    
    init(messenger: FlutterBinaryMessenger) {
        self.messenger = messenger
        super.init()
    }
    
    func callGyeomViewMethod(){
        if let gyeomView = gyeomView {
            gyeomView.receiveGyeomViewMethod()
        }
    }

    func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any?) -> FlutterPlatformView {
        
        self.gyeomView = GyeomView(
            frame: frame,
            viewIdentifier: viewId,
            arguments: args,
            binaryMessenger: messenger)
        return gyeomView ?? GyeomView(
            frame: frame,
            viewIdentifier: viewId,
            arguments: args,
            binaryMessenger: messenger)
    }
    
    
    
}

/**
FlutterPlatformView를 상속받는 클래스: 실제 플러터 내에 포함될 UIView를 리턴하는 클래스(func view() -> UIView {}가 프로토콜내 필수
 구현함수로 구현되어 있다)
 */
class GyeomView: NSObject, FlutterPlatformView{
    private var returnView: UIView?
    var nativeLabel = UILabel()
    
    
    override init() {
        returnView = UIView()
        super.init()
    }
    
    
    init(
        frame: CGRect,
        viewIdentifier viewId: Int64,
        arguments args: Any?,
        binaryMessenger messenger: FlutterBinaryMessenger?
    ) {
        returnView = UIView()
        super.init()
        // iOS views can be created here
        createNativeView(view: returnView!, args: args)
    }
    
    func view() -> UIView {
        return returnView!
    }
    
    func receiveGyeomViewMethod(){
        print("receiveGyeomViewMethod")
    }
    
    func createNativeView(view _view: UIView, args: Any?){
        print("createNativeView args: \(args)")
        _view.backgroundColor = UIColor.blue
        nativeLabel.text = "이것은 GyeomView"
        nativeLabel.textColor = UIColor.white
        nativeLabel.textAlignment = .center
        nativeLabel.frame = CGRect(x: 0, y: 0, width: 180, height: 48.0)
        nativeLabel.translatesAutoresizingMaskIntoConstraints = false
        _view.addSubview(nativeLabel)
        nativeLabel.centerXAnchor.constraint(equalTo: _view.centerXAnchor).isActive = true
        nativeLabel.centerYAnchor.constraint(equalTo: _view.centerYAnchor).isActive = true
        
        
        if let args = args {
            if let param = args as? [String:Any]{
                print("param: \(param["param"] ?? "파라미터 없음")")
            }
        }
    }
}

소스 해석은 저 위에 주석부분을 보면 된다. 이런 함수들을 구현할땐 상속받는 클래스, 프로토콜을 꼭 들어가서 보자. 보면, 왜 func view()라는 함수가 덩그러니 있는지, create(...)는 무슨 함수인지인지에 대해 알 수 있다.

 

여튼 여기서 볼 것은, returnView라는 객체가 플러터의 UIKitView라는 위젯에 들어가 사용자에게 보여지는 것으로 이해하면 된다.

 

print문을 보면 아래와 같다.

createNativeView args: Optional({
    param = "\Uacb8\Uc2dd\Uc774";
})
param: 겸식이

[초기 UI를 그릴때 주의사항]

위와 같이 return되는 뷰의 경우, 바로 문제가 없지만 만약 UI를 그리는데 비동기적인 작업이 들어가거나 할경우에는, initView에서 작업을 수행하는 것을 지양하도록하자. Flutter에서는 UIKitView를 호출했고, iOS단에서는 작업자가 지정한 뷰를 리턴하는데 만약 지정한 뷰에 특정 시간작업이 필요하다면 플러터에서 UIKitView를 호출하는 것과 initView에서의 작업이 충돌하여 잘 그려지지 않는 문제가 발생할 수 있기 때문이다. 이럴때는 Flutter쪽에서 UIKitView내에 있는 onPlatformViewCreated를 사용하도록하자. 

	UiKitView(
              viewType: _viewType,
              layoutDirection: TextDirection.ltr,
              creationParams: creationParams,
              creationParamsCodec: const StandardMessageCodec(),
              onPlatformViewCreated: (int id) {
                print('[Gyeom] UiKitView onPlatformViewCreated aw:$id');
                _startPlayer();
              },
            ),