20200406のiOSに関する記事は5件です。

遷移しよー!with CircleMenu

こんにちは!!!

手元からPCが消え去って1ヶ月。やっっっとパソコンが買えたので投稿。

今回はCircleMenuというオシャレなライブラリがあることを(今更)知ったのでこのアニメーションを利用してふわっと遷移しようという趣旨。

別にCircleMenuのオシャレなアニメーションに乗っかって遷移してるだけのハリボテです。

ブランクあるんや、、リハビリみたいなもんや、、

成果物

なんかgifだと残念な感じに円が残るけど実際には普通にスゥーっ!っと消えます!!!(血眼)

Unknown

コード

import UIKit
import CircleMenu

class ViewController: UIViewController {

    let menuButton: CircleMenu = {
        let view = CircleMenu(frame: CGRect(x: UIScreen.main.bounds.width / 2 - 25, y: UIScreen.main.bounds.height / 2 - 25, width: 50, height: 50), normalIcon: "menu", selectedIcon: "error", buttonsCount: 5, duration: 0.3, distance: 150)
        view.backgroundColor = .green
        view.layer.cornerRadius = view.frame.size.width / 2.0
        return view
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        menuButton.delegate = self

        self.view.addSubview(menuButton)

    }
}

extension ViewController: CircleMenuDelegate {
    func circleMenu(_ circleMenu: CircleMenu, buttonDidSelected button: UIButton, atIndex: Int) {
        var vc: UIViewController?
        switch atIndex {
        case 0:
            vc = NextViewController()
        case 1:
            vc = NextViewController()
        case 2:
            vc = NextViewController()
        case 3:
            vc = NextViewController()
        case 4:
            vc = NextViewController()
        default:
            print("no vc")
        }
        guard let VC = vc else { return }
        VC.modalPresentationStyle = .overCurrentContext
        self.present(VC, animated: false, completion: nil)
    }
}


import UIKit

class NextViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.backgroundColor = .red

        let tap = UITapGestureRecognizer(target: self, action: #selector(dismissAction))
        self.view.addGestureRecognizer(tap)
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        self.view.alpha = 0
        UIView.animate(withDuration: 0.5) {
            self.view.alpha = 1
        }

    }

    @objc func dismissAction() {
        self.dismiss(animated: false, completion: nil)
    }
}

解説

import らへん

import CircleMenu

今回のメインであるライブラリのインポートですね。githubではbuildの項目がfailingになってましたけど普通に使えますね。なんででしょ。余裕があったら読みたいものですな。

view らへん

let menuButton: CircleMenu = {
        let view = CircleMenu(frame: CGRect(x: UIScreen.main.bounds.width / 2 - 25, y: UIScreen.main.bounds.height / 2 - 25, width: 50, height: 50), normalIcon: "menu", selectedIcon: "error", buttonsCount: 5, duration: 0.3, distance: 150)
        view.backgroundColor = .green
        view.layer.cornerRadius = view.frame.size.width / 2.0
        return view
    }()

viewの宣言をクロージャで宣言してるんや。これは宣言してるviewが使われるときに一度だけ初期化されるヨ。
この形をとることでひとまとまりになってるし、viewDidload内が汚れないから見やすいね!
あとはあんまり良くないかもしれないけど、中で宣言するインスタンス名をviewにすることで他のviewを宣言するときコピペがとっても楽になるよ!笑

delegate らへん

delegateは移譲って言って、こいつがいるときは処理を他人任せにしてる証拠だ!!(そして大抵僕に丸投げされてるんだ。 これは私情。)

もし君が仕事を任されたとき自分なりのやり方でやるよね???(異論ナシ。)
delegateも任された側のやり方で処理することができるから便利なんだ。
delegateと一緒だね!!!!(歓喜)

あと、通知の役割も大きいけど今回は使ってないからパス _ (:3 」)_

menuButton.delegate = self

このコードからは、menuButton先輩が、self、つまり私(ViewController)に仕事をぶん投げるということを高らかに宣言しているのだ。(やめてくれよ)

逆に言えば、このように高らかに宣言してくれないとdelegateできない。つまり仕事を任せられていないので、

「お前これ頼んだよな?(威圧)」
「は?寝ぼけてんのか?(えっ、??いや、頼まれてないですよ、、?)」

なんてことが起こる。(特に良く僕のコード内で。うっかり。照)

Extension らへん

swiftに限らず継承はとても便利だ。
ここでは便利なextensionの継承の、書き方について触れておこうと思う。

extension ViewController: CircleMenuDelegate {
  //省略
}

プロトコルを継承することで決まった形のクラスや構造体を作れるし、うまくかければ具象への依存かなり減らせてそれだけでとても変更しやすくなる。

しかし、ViewControllerの定義でついつい一度に継承するものを羅列してしまいがちだ。

class ViewController: UIViewController, UITableViewDelegate, CircleMenuDelegate {
  //省略
}

これではどの関数がどのクラス・プロトコルから継承してきたものかかなり分かりづらいし、区切りのないコードが一気に肥大してしまう。

そのため、今回のように一つ一つ優しくextensionしてあげよう。これだけで見通しが良くなるし、「あっ、こいつイラネ!w」となった時もどこを消せばいいかすぐわかる。
追加もしやすい。

書き方をちょっと変えるだけでいいことづくめなのだ。

guard らへん

guardとif。ifが便利すぎでわざわざguardを使う意味がわからないという方もいるのではなかろうか。

ペーペーの僕がいうのもなんだが、僕なんかよりずっといいコードを書いている人でもguardを使わずifを使っていることがあるから頭に「?」が浮かぶ。

こんなに良い関数は他にないんじゃないか? それは無いか。
(ちなみに僕的、昨今の推しはmapです。)

guard let VC = vc else { return }

guardはなんと言っても一眼で役割が分かるのがいい。

  • guardを使う場面はreturnされて本土(呼び出し元)に返されるか、fatalErrorでも呼ばれてアプリがクラッシュするかくらいだ。

「いやそれifでもできるやーん?」

そうなんだけど、あえてguardを使うことで自分や他の人がそこを読む時、一瞬で意図がわかる。

素敵だ。(イケボ)

そしてしばしばletとセットでオプショナル回避に用いられる。

let optional: Int? = 3

guard let Nakami = optional else { return }
// optional : Optional<3>
// Nakami : 3

このように中身を簡単かつ安全に、そして中身がない時は本土に返還するスグレモノなのだ。

lifecycle と animation らへん

animationを行うことで今回はそれっぽーく仕上げでいる。
透明度を表すalphaの値を事前に0にして完全に透明にしておく。

viewWillAppearが呼ばれたらアニメーションを行って、alphaを1すればふわっと素敵っぽい遷移が実現できた。(急に完了する。)

override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        self.view.alpha = 0
        UIView.animate(withDuration: 0.5) {
            self.view.alpha = 1
        }
    }

ここで大事なのでviewWillAppearで呼ぶことだ。

これより前のviewDidloadで呼ぶと、そもそもアニメーションが行われない。
しかもvcを再利用していればviewDidloadは一回しか呼ばれない。

この後のviewWillLayoutSubviewsは名前の通りサブビューの描画準備のためにあるので役割として明らかにおかしい。

ここら辺に関してはもっと良い書き方があるんだろうなぁという感覚なので勉強します。

ここまで読んでいただきありがとうございました。

キーボードは配列が前のは海外仕様だったから逆に日本仕様に慣れない、、

書くつもりなかったのにPCきたの嬉しすぎて夢中になって書いてしまった、、
明日仕事なのに。もう寝なきゃ。

おやすみなさい

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Flutter】BLoCパターンでアプリを作成してみた。~カウンターアプリ、githubリポジトリ検索アプリ~

BLoCパターンとは

BLoC PatternはFlutterでのアプリケーション開発時に用いる、状態管理手法の1つです。
ビジネスロジックをコンポーネント単位で管理しやすくするためのパターンです。

こちらを参考にしてください。

サンプルアプリの紹介

Githubはこちら

カウンターアプリ

プラスボタン、マイナスボタンを押下することで画面中央の数字がインクリメント、デクリメントされます。

カウンターアプリカウンターアプリ

githubリポジトリ検索アプリ

TextFieldに検索キーワードを入力して、検索すると対象のGitHubリポジトリの一覧を表示して、要素をタップするとWebViewで表示します。

githubリポジトリ検索アプリgithubリポジトリ検索アプリ

ソースコード解説

main.dart
import 'Model/counter_bloc.dart';
import 'Model/search_bloc.dart';

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

import 'UI/counter_page.dart';
import 'UI/search_page.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: MultiProvider(
          providers: [
            Provider<CounterBloc>(
              create: (context) => CounterBloc(),
              dispose: (context, bloc) => bloc.dispose(),
            ),
            Provider<SearchBloc>(
              create: (context) => SearchBloc(),
              dispose: (context, bloc) => bloc.dispose(),
            ),
          ],
          child: MyHomePage(title: 'Flutter BLoC Sample'),
        )
    );
  }
}

class MyHomePage extends StatelessWidget {
  MyHomePage({this.title});

  final String title;

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      child: Scaffold(
        appBar: AppBar(
          title: Text(title),
          bottom: TabBar(
            tabs: <Widget>[
              new Tab(
                text: "Count",
              ),
              new Tab(
                text: "Search",
              ),
            ],
          ),
        ),
        body: TabBarView(
          children: <Widget> [
            CounterPage(),
            SearchPage(),
          ],
        ),
        // This trailing comma makes auto-formatting nicer for build methods.
      ),
    );
  }
}

Providerの利用

Providerを使うことで、childパラメータに指定したWidget以下全てのWidgetで、同じBLoCインスタンスにアクセスすることができます。

複数のProviderを設定する場合は、MultiProviderを設定します。

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: MultiProvider(
          providers: [
            Provider<CounterBloc>(
              create: (context) => CounterBloc(),
              dispose: (context, bloc) => bloc.dispose(),
            ),
            Provider<SearchBloc>(
              create: (context) => SearchBloc(),
              dispose: (context, bloc) => bloc.dispose(),
            ),
          ],
          child: MyHomePage(title: 'Flutter BLoC Sample'),
        )
    );
  }
}

Counter UIの作成

counter_page.dart
import '../Model/counter_bloc.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class CounterPage extends StatelessWidget {
  CounterPage();
  @override
  Widget build(BuildContext context) {
    final counterBloc = Provider.of<CounterBloc>(context);
    return new Scaffold(
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Text(
                'You have pushed the button this many times:',
              ),
              StreamBuilder(
                initialData: 0,
                stream: counterBloc.count,
                builder: (context, snapshot) {
                  return Text(
                    '${snapshot.data}',
                    style: Theme.of(context).textTheme.display1,
                  );
                },
              )
            ],
          ),
        ),
        floatingActionButton: Column(
          verticalDirection: VerticalDirection.up, // childrenの先頭を下に配置
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            FloatingActionButton(
              onPressed: () {
                counterBloc.changeCountAction.add(false);
              },
              tooltip: 'Declement',
              child: Icon(Icons.remove),
            ),
            Container( // 余白のためContainerでラップ
              margin: EdgeInsets.only(bottom: 16.0),
              child: FloatingActionButton(
                onPressed: () {
                  counterBloc.changeCountAction.add(true);
                },
                tooltip: 'Increment',
                backgroundColor: Colors.redAccent,
                child: Icon(Icons.add),
              ),
            ),
          ],
        )
    );
  }
}

Counter BLoCの生成

counter_bloc.dart
import 'dart:async';
import 'package:rxdart/rxdart.dart';

class CounterBloc {
  // input
  final _actionController = BehaviorSubject<bool>();
  Sink<void> get changeCountAction => _actionController.sink;

  //output
  final _countController = BehaviorSubject<int>();
  Stream<int> get count => _countController.stream;

  int _count = 0;

  CounterBloc() {
    _actionController.stream.listen((isPlus) {
      if (isPlus) {
        _count++;
      } else {
        _count--;
      }
      _countController.sink.add(_count);
    });
  }

  void dispose() {
    _actionController.close();
    _countController.close();
  }
}

BLoCの呼び出し

BLoCは、子Widgetのbuild()メソッドで呼ぶのが定番です。

  @override
  Widget build(BuildContext context) {
    final counterBloc = Provider.of<CounterBloc>(context);
    return new Scaffold(

Sink<T>.add() でBLoCに値を送る

例では、counterBloc.changeCountActionに対して、プラスならTrue,マイナスならFalseのBooleanを渡しています。

              child: FloatingActionButton(
                onPressed: () {
                  counterBloc.changeCountAction.add(true);
                },

Stream.listenでinputの値に対して処理を実行

Stream.listenで流れてきたBooleanを受け取る。

ちなみにStreamはこんなイメージを持ってもらえば良いと思う。
用意された川に対して、今回だとBooleanの要素をプラスボタンやマイナスボタンが押される度に流されるイメージ。
image.png
引用: https://medium.com/@teivah/reactivewm-a-reactive-framework-for-webmethods-2c91c7de82b3

  CounterBloc() {
    _actionController.stream.listen((isPlus) {
      if (isPlus) {
        _count++;
      } else {
        _count--;
      }
      _countController.sink.add(_count);
    });
  }

以下のコードで再度、別のStreamに要素(この例ではint)を流している。
Dart
_countController.sink.add(_count);

StreamBuilderで値の受け取り

StreamBuilderを使って、Streamの値を反映します。StreamBuilderを使うことで、build()メソッドを呼ぶことなくStreamの値に応じてこの箇所だけUIを更新することができます。

              StreamBuilder(
                initialData: 0,
                stream: counterBloc.count,
                builder: (context, snapshot) {
                  return Text(
                    '${snapshot.data}',
                    style: Theme.of(context).textTheme.display1,
                  );
                },
              )

これで、カウンターアプリは完成です。

続いて、、、githubのリポジトリ検索アプリを紹介

検索画面UIの生成

search_page.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../Model/search_bloc.dart';
import 'package:flutter_inappbrowser/flutter_inappbrowser.dart';

class SearchPage extends StatelessWidget {
  final _formKey = GlobalKey<FormState>();

  TextEditingController queryInputController = TextEditingController(text: '');

  @override
  Widget build(BuildContext context) {
    final searchBloc = Provider.of<SearchBloc>(context);
    return Scaffold(
        body: Center(
            child: Column(children: <Widget>[
              Form(
                  key: _formKey,
                  child: Column(children: <Widget>[
                    Padding(
                        padding: const EdgeInsets.all(16.0),
                        child: Center(
                            child: TextFormField(
                              decoration: InputDecoration(
                                labelText: '検索キーワード',
                              ),
                              controller: queryInputController,
                            ))),
                    RaisedButton(
                        child: const Text('検索'),
                        onPressed: () =>
                            searchBloc.changeQuery.add(queryInputController.text)),
                  ])),
              StreamBuilder(
                  stream: searchBloc.result,
                  builder: (context, snapshot) {
                    if (snapshot.hasError) {
                      print(snapshot.error);
                      // snapshot.error を使ったWidgetを返す
                      // snapshot は AsyncSnapshot<T> で
                    }
                    if (snapshot.data != null) {
                      return Expanded(
                        child: ListView.builder(
                            scrollDirection: Axis.vertical,
                            shrinkWrap: true,
                            itemCount: snapshot.data.length,
                            itemBuilder: (context, int index) {
                              var item = snapshot.data[index];
                              return Container(
                                  decoration: BoxDecoration(
                                    border: Border(
                                      bottom: BorderSide(color: Colors.black38),
                                    ),
                                  ),
                                  child: ListTile(
                                    title: Text(item['full_name']),
                                    subtitle: Text('Star: ' + item['stargazers_count'].toString()),
                                    onTap: () {
                                      this.openBrowser(url: item['html_url']);
                                    },
                                  ));
                            }),
                      );
                    }
                    return Container();
                  })
            ])));
  }

  openBrowser({String url}) {
    ChromeSafariBrowser browser = ChromeSafariBrowser();
    browser.open(url: url, options: ChromeSafariBrowserClassOptions(
      androidChromeCustomTabsOptions: AndroidChromeCustomTabsOptions(
        addShareButton: true,
        toolbarBackgroundColor: "#ff0000",
        enableUrlBarHiding: true,
      ),
      iosSafariOptions: IosSafariOptions(
        barCollapsingEnabled: true,
      )
    ));
  }
}

検索BLoCの生成

search_bloc.dart
import 'dart:async';
import 'dart:convert';
import 'package:flutterbloc/Model/API/api_service.dart';
import 'package:rxdart/rxdart.dart';
import 'API/chopper_client_creater.dart';

import 'package:chopper/chopper.dart';

class SearchBloc {
  final searchApi = SearchApi();

  final searchQueryController = BehaviorSubject<String>();
  Stream<String> get query => searchQueryController.stream;
  StreamSink<String> get changeQuery => searchQueryController.sink;

  // APIの返り値となるSearchResult型を自作したと仮定
  final searchResultController = BehaviorSubject<List<dynamic>>();
  Stream<List<dynamic>> get result => searchResultController.stream;
  StreamSink<List<dynamic>> get changeResult => searchResultController.sink;

  SearchBloc() {
    query.listen((v) async {
      // APIの返り値となるSearchResult型を自作したと仮定
      final List<dynamic> searchResults = await searchApi.fetchApi(query: v);
      print("------");
      print(searchResults);
      print("------");
      if (searchResults.isEmpty) {
        changeResult.addError(searchResults);
      } else {
        changeResult.add(searchResults);
      }
    });
  }

  void dispose() {
    searchResultController.close();
    searchQueryController.close();
  }
}

class SearchApi {
  final ApiService service =
  ApiService.create(ChopperClientCreator.create());

  Future<List<dynamic>> fetchApi({String query}) async {
    final Response response = await service.fetchApi(query: query);
    if (response.isSuccessful) {
      return response.body['items'];
    } else {
      print(response.error);
    }
  }
}

APIモデルの生成

今回はChopperを利用しました。

こちら参考記事になります。

api_service.dart
import 'package:chopper/chopper.dart';

part 'api_service.chopper.dart';

@ChopperApi(baseUrl: '')
abstract class ApiService extends ChopperService {
  static ApiService create([ChopperClient client]) =>
      _$ApiService(client);

  @Get(path: "/repositories")
  Future<Response> fetchApi({
    @Query('q') String query,
    @Query('sort') String sort = 'stars'
  });
}
chopper_client_creater.dart
import 'package:chopper/chopper.dart';

class ChopperClientCreator {
  static final String baseUrl = "https://api.github.com/search";

  static ChopperClient create() {
    return ChopperClient(
      baseUrl: ChopperClientCreator.baseUrl,
      converter: JsonConverter(),
    );
  }
}
api_service.chopper.dart
// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'api_service.dart';

// **************************************************************************
// ChopperGenerator
// **************************************************************************

// ignore_for_file: always_put_control_body_on_new_line, always_specify_types, prefer_const_declarations
class _$ApiService extends ApiService {
  _$ApiService([ChopperClient client]) {
    if (client == null) return;
    this.client = client;
  }

  @override
  final definitionType = ApiService;

  @override
  Future<Response<dynamic>> fetchApi({String query, String sort = 'stars'}) {
    final $url = '/repositories';
    final $params = <String, dynamic>{'q': query, 'sort': sort};
    final $request = Request('GET', $url, client.baseUrl, parameters: $params);
    return client.send<dynamic, dynamic>($request);
  }
}

参考記事

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

GitLab で Unity の iOS アプリを自動ビルドするようにしてみた

GitLab で Unity の iOS アプリを自動ビルドするようにしてみた

GitLab で Unity の iOSアプリを自動ビルドするようにしてみました。
ipaファイルを配布するようにしたかったのですが、無料の範囲ではできないようです。
自動ビルドして作成した ipaファイルは Mac の Xcode を使って iPhone へインストールします。

環境

  • macOS Catalina 10.15.4
  • GitLab CE 12.8.6(Windows10上の仮想サーバーで動かしています)
  • GitLab Runner 12.9.0(Mac上で動かしています)
  • Unity 2018.4.20f1

フォルダ構成

ファイル/フォルダ 内容
TestProject_Unity/ Unityプロジェクトフォルダ
TestProject_Unity/.gitignore Git無視リストファイル
TestAutoTest/TestProject_Unity/Assets/Editor/AppBuild.cs ビルド実行メソッドC#ファイル
.gitlab-ci.yml GitLab CI 設定ファイル
build.sh Unityプロジェクトのビルド用シェルスクリプトファイル
build_xcode.sh Xcodeプロジェクトのビルド用シェルスクリプトファイル
ExportOptions.plist xcodebuildのarchiveエクスポート用plistファイル
README.md 説明ファイル

注意点

Unity で Identification の設定をする

Unity の Build Settings -> Player Settings で PlayerSettings を開きます。
iOS の Identification にある項目を設定します。
設定する内容は、Provisioning Profiles などで確認します。
- Bundle Identifier
- Signing Team ID
- Automatically Sign
※Automatically Sign にチェックを入れました。

Provisioning Profiles の作成方法

Xcode の Preferences -> Accounts で Manage Certificatesボタンを押します。
右下の +ボタンから iOS Development で Provisioning Profile を作成します。

Provisioning Profiles の確認方法

下記のフォルダに配置されている .mobileprovisionファイルを選択してスペースキーを押すと内容が表示されます。
~/Library/MobileDevice/Provisioning Profiles

ipaファイルの配布方法

  • iOS Development の場合(無料の範囲内 ←今回はこっち)

Xcode で ipaファイルをインストール可能です。
Xcode の Window -> Devices and Simulators でウィンドウを開いてドラッグアンドドロップでインストールできます。

  • iOS Distribution の場合(課金が必要のようだ)

iTunes で ipaファイルをインストール可能です。

iPhone のインストール時の信頼

iPhone の 一般 -> プロファイルとデバイス管理 -> デベロッパAPP で信頼します。

ExportOptions.plist について

xcodebuildコマンドの -exportOptionsPlistオプションで指定する plistファイルについては、下記のコマンドで確認できます。

xcodebuild -h

今回 plistファイル内で使用した key については下記のとおりです。(Google翻訳つき)

  • compileBitcode : Bool
    For non-App Store exports, should Xcode re-compile the app from bitcode? Defaults to YES.
    App Store以外のエクスポートの場合、Xcodeはビットコードからアプリを再コンパイルする必要がありますか?デフォルトはYESです。

  • method : String
    Describes how Xcode should export the archive. Available options: app-store, validation, package, ad-hoc, enterprise, development, developer-id, and mac-application. The list of options varies based on the type of archive. Defaults to development.
    Xcodeがアーカイブをエクスポートする方法について説明します。利用可能なオプション:app-store、検証、パッケージ、アドホック、エンタープライズ、開発、developer-id、mac-application。オプションのリストは、アーカイブのタイプによって異なります。デフォルトは開発です。

  • provisioningProfiles : Dictionary
    For manual signing only. Specify the provisioning profile to use for each executable in your app. Keys in this dictionary are the bundle identifiers of executables; values are the provisioning profile name or UUID to use.
    手動署名のみ。アプリの各実行可能ファイルに使用するプロビジョニングプロファイルを指定します。この辞書のキーは、実行可能ファイルのバンドル識別子です。値は、使用するプロビジョニングプロファイル名またはUUIDです。

  • signingCertificate : String
    For manual signing only. Provide a certificate name, SHA-1 hash, or automatic selector to use for signing. Automatic selectors allow Xcode to pick the newest installed certificate of a particular type. The available automatic selectors are "Mac App Distribution", "iOS Distribution", "iOS Developer", "Developer ID Application", and "Mac Developer". Defaults to an automatic certificate selector matching the current distribution method.
    手動署名のみ。署名に使用する証明書名、SHA-1ハッシュ、または自動セレクターを指定します。自動セレクターにより、Xcodeは特定のタイプの最新のインストール済み証明書を選択できます。利用可能な自動セレクターは、「Mac App Distribution」、「iOS Distribution」、「iOS Developer」、「Developer ID Application」、および「Mac Developer」です。デフォルトは、現在の配布方法に一致する自動証明書セレクターです。

  • signingStyle : String
    The signing style to use when re-signing the app for distribution. Options are manual or automatic. Apps that were automatically signed when archived can be signed manually or automatically during distribution, and default to automatic. Apps that were manually signed when archived must be manually signed during distribtion, so the value of signingStyle is ignored.
    配布用にアプリに再署名するときに使用する署名スタイル。オプションは手動または自動です。アーカイブ時に自動的に署名されたアプリは、手動または配布時に自動的に署名でき、デフォルトでは自動です。アーカイブ時に手動で署名されたアプリは、配布時に手動で署名する必要があるため、signingStyleの値は無視されます。

  • stripSwiftSymbols : Bool
    Should symbols be stripped from Swift libraries in your IPA? Defaults to YES.
    IPAのSwiftライブラリからシンボルを削除する必要がありますか?デフォルトはYESです。

  • teamID : String
    The Developer Portal team to use for this export. Defaults to the team used to build the archive.
    このエクスポートに使用する開発者ポータルチーム。デフォルトでは、アーカイブの作成に使用されたチームになります。

  • thinning : String
    For non-App Store exports, should Xcode thin the package for one or more device variants? Available options: (Xcode produces a non-thinned universal app), (Xcode produces a universal app and all available thinned variants), or a model identifier for a specific device (e.g. "iPhone7,1"). Defaults to .
    App Store以外のエクスポートの場合、Xcodeは1つ以上のデバイスバリアントのパッケージを薄くする必要がありますか?利用可能なオプション:(Xcodeは非薄型のユニバーサルアプリを生成します)、(Xcodeはユニバーサルアプリと利用可能なすべての薄型のバリアントを生成します)、または特定のデバイスのモデル識別子(例:「iPhone7,1」)。デフォルトはです。

ファイルの内容

TestProject_Unity/.gitignore

/[Ll]ibrary/
/[Tt]emp/
/[Oo]bj/
/[Bb]uild/
/[Bb]uilds/
/Assets/AssetStoreTools*

# Autogenerated VS/MD solution and project files
ExportedObj/
*.csproj
*.unityproj
*.sln
*.suo
*.tmp
*.user
*.userprefs
*.pidb
*.booproj
*.svd

# Unity3D generated meta files
*.pidb.meta

# Unity3D Generated File On Crash Reports
sysinfo.txt

# Builds
*.apk
*.unitypackage

# Visual Studio
.vs
Release/
Debug/

.gitlab-ci.yml

stages:
  - build

job_build02:
  stage: build
  script:
    - echo "Start build"
    - chmod a+x ./build.sh
    - chmod a+x ./build_xcode.sh
    - ./build.sh iOS 2.3.4.5
    - ./build_xcode.sh iOS 2.3.4.5
    - echo "Finish build"
  tags:
    - mac
  artifacts:
    paths:
      - build_*.log
      - TestProject_Unity/Build/
    expire_in: 1 week

TestAutoTest/TestProject_Unity/Assets/Editor/AppBuild.cs

using UnityEditor;
using UnityEngine;
using UnityEditor.Build.Reporting;
using System;
using System.Collections.Generic;

public class AppBuild : MonoBehaviour
{
    static string[] GetSceneList()
    {
        List<string> sceneList = new List<string>();
        EditorBuildSettingsScene[] scenes = EditorBuildSettings.scenes;
        foreach (EditorBuildSettingsScene scene in scenes)
        {
            if (scene.enabled)
            {
                sceneList.Add(scene.path);
            }
        }
        return sceneList.ToArray();
    }

    static string GetArgument(string name)
    {
        string[] arguments = Environment.GetCommandLineArgs();
        for (int i = 0; i < arguments.Length; i++)
        {
            if (arguments[i].Contains(name))
            {
                return arguments[i + 1];
            }
        }
        return null;
    }

    public static void Build()
    {
        string locationPathName = "";
        BuildTarget target = BuildTarget.NoTarget;
        string bundleVersion = GetArgument("/version");;
        string platformName = GetArgument("/platform");
        switch (platformName.ToLower())
        {
            case "android":
                {
                    locationPathName = "Build/" + platformName + "_" + bundleVersion + ".apk";
                }
                break;
            case "ios":
                {
                    locationPathName = platformName + "Project_" + bundleVersion;
                }
                break;
            default:
                {
                    locationPathName = "Build/" + platformName + "_" + bundleVersion;
                }
                break;
        }
        target = (BuildTarget)Enum.Parse(typeof(BuildTarget), platformName);

        BuildPlayerOptions buildPlayerOptions = new BuildPlayerOptions();
        buildPlayerOptions.scenes = GetSceneList();
        buildPlayerOptions.locationPathName = locationPathName;
        buildPlayerOptions.target = target;
        buildPlayerOptions.options = BuildOptions.Development;

        PlayerSettings.bundleVersion = bundleVersion;

        BuildReport report = BuildPipeline.BuildPlayer(buildPlayerOptions);
        BuildSummary summary = report.summary;

        if (summary.result == BuildResult.Succeeded)
        {
            Debug.Log("Build succeeded: " + summary.totalSize + " bytes");
        }

        if (summary.result == BuildResult.Failed)
        {
            Debug.Log("Build failed");
        }
    }
}

build.sh

#!/bin/bash

SHELL_PATH=`pwd`
PLATFORM=$1
VERSION=$2

UNITY=/Applications/Unity/Hub/Editor/2018.4.20f1/Unity.app/Contents/MacOS/Unity
LOGFILE=build_${PLATFORM}.log
PROJECT_PATH=TestProject_Unity
EXECUTE_METHOD=AppBuild.Build

${UNITY} -batchmode -quit -logFile ${LOGFILE} -projectPath "${SHELL_PATH}/${PROJECT_PATH}" -executeMethod ${EXECUTE_METHOD} /platform ${PLATFORM} /version ${VERSION}

build_xcode.sh

#!/bin/bash

SHELL_PATH=pwd
PLATFORM=$1
VERSION=$2

SCHEME="Unity-iPhone"
UNITY_PROJECT_PATH="TestProject_Unity"
XCODE_PROJECT_PATH="${UNITY_PROJECT_PATH}/${PLATFORM}Project_${VERSION}/${SCHEME}.xcodeproj"
ARCHIVE_PATH="archive/${SCHEME}.xcarchive"
IPA_PATH="TestProject_Unity/Build/ipa"

EXPORT_OPTIONS_PLIST="ExportOptions.plist"
PROVISIONING_PROFILE="(Provisioning ProfileのUUID)"

mkdir -p ${ARCHIVE_PATH}

xcodebuild -project ${XCODE_PROJECT_PATH} -scheme ${SCHEME} archive -archivePath ${ARCHIVE_PATH}
xcodebuild -exportArchive -archivePath ${ARCHIVE_PATH} -exportPath ${IPA_PATH} -exportOptionsPlist ${EXPORT_OPTIONS_PLIST} PROVISIONING_PROFILE=${PROVISIONING_PROFILE}

ExportOptions.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>compileBitcode</key>
    <false/>
    <key>method</key>
    <string>development</string>
    <key>provisioningProfiles</key>
    <dict>
        <key>(Bundle Identifier)</key>
        <string>(Provisioning ProfileのUUID)</string>
    </dict>
    <key>signingCertificate</key>
    <string>iOS Developer</string>
    <key>signingStyle</key>
    <string>automatic</string>
    <key>stripSwiftSymbols</key>
    <true/>
    <key>teamID</key>
    <string>(Signing Team ID)</string>
    <key>thinning</key>
    <string>&lt;none&gt;</string>
</dict>
</plist>

まとめ

作成した ipaファイルを自由に配布はできないようです。
無料の範囲内では、Xcode を使ってインストールするしかなさそうです。
今回の自動ビルドで ipaファイルの作成までは自動化できるので時間の節約にはなるかと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

PokéAPIを利用してMVP+CleanArchitectureのiOSアプリを作ったので解説する

はじめに

最近、勉強会などで、iOSの業務未経験の人たちと話している時に、これからiOSエンジニアとして仕事を得るためにどういったことを学べばいいのか、業務で実際にどういうことを意識して設計やツールを駆使しているかということをよく聞かれるので、それについて説明することができたらなと感じていました。

実際に業務で作っているコードを公開することはできないので、業務で作っているコードにかなり近い形のサンプルのアプリを作成、公開したので、それを参考にしながら私がどういったことを考えてiOSの業務をこなしているかについてを解説していきます。

注意:これはあくまで私が個人的に思う業務における考え方であって、正解というわけではありません。一つの意見として捉えてもらえると幸いです。

最も重要なことは何か

私は、iOSに限らずアプリケーション開発の業務を遂行する上で、最も重要なことは、アプリケーションのリリースサイクルを短くして、ユーザーに最速で最大の価値を提供し続けることだと考えています。

それを達成するためには、以下の様なことを意識するのが効果的と考えて、業務で実践しています。

  • 自動化をする
  • ミスを減らす
  • 開発におけるストレスを減らす

今回はそういったことを意識して、業務で実際に使っている、ミスが起こりにくく、素早い変更や改修、機能追加を容易にするためのノウハウを実践的に詰め込んだアプリを作成しました。

Pokedex

そのノウハウを詰め込んだサンプルのアプリですが、普段業務で考えていることを解説するという、少し退屈なテーマを目的としたものである以上、せめてアプリのテーマだけでも面白いものにしたいと思い、今回はPokéAPIを利用してみました。

Pokedex

ポケモンの一覧と詳細を見ることができる簡単なポケモン図鑑のアプリです。
Pokedex

セットアップ編

では、早速コードを見て解説と言いたいところですが、このアプリを起動するまでには、少しセットアップの作業が必要なので、それをまずはしていきましょう。

Makefile

Makefileに従ってセットアップを行えば、ツールやライブラリのインストールなどの面倒な作業をかなり簡単にできるのでオススメです。

実際の業務では、新たにチームメンバーが参加した時などに役立つでしょう。

README.mdに書いてあるとおり、ターミナルでプロジェクトのルートを開き、make bootstrapmake projectのコマンドを実行します。

今回インストールしたツールはiOS開発を行う上で、効率を上げてくれるので詳細を説明するべきなのですが、全部を細かく紹介すると、かなり長くなってしまうので、それぞれについてさらっと紹介して、参考になる記事を貼り付けておきます。

Mint

Swift製のコマンドラインツールを管理することができるツールです。

今回はこれを利用して、XcodeGenなどの開発する際に便利なツールを管理します。

Mint で Swift 製のコマンドラインツールを管理する

Swift製コマンドラインツールのパッケージ管理ツール「Mint」のセットアップ&操作方法

Carthage

言わずとしれたライブラリ管理ツールです。

CocoaPodsに比べて、ライブラリのインストール時間が長いですが、その分コンパイル時間が短縮できるので、オススメです。

Carthageを使ってビルド時間を短縮しよう

XcodeGen

チームで開発しているとよくあるのですが、ファイル追加による.xcodeprojのコンフリクトに悩まされます。

ターミナルでxcodegenのコマンドを実行すれば、project.ymlファイルに記載されている内容から.xcodeprojを生成してくれるので、コンフリクトのストレスから解放されます。

Pokedexは一人で開発したので、コンフリクトの問題は元々起こりにくい環境ではあるのですが、マルチモジュールで開発しているため、その辺りの管理もやりやすいことから導入するメリットがあるなと思ってます。

XcodeGenによる新時代のiOSプロジェクト管理

SwiftGen

画像や色などのリソースを取得するためのファイルを自動生成してくれるツールです。

画像の呼び出しなどをする際の文字列の指定をtypoしてしまい取得できないといった、ミスを無くすことができます。

SwiftGenを導入して無駄なビルド負担を低減する

R.swiftとSwiftGenの導入方法とどちらを採用した方がいいのか

SwiftLint

.swiftlint.ymlファイルに記載されたコードのルールに応じて、ビルド時に警告を
出したりコンパイルエラーにさせることができるツールです。

コードにルールを設けることで書き方が統一されるので、コードの読みやすさが向上します。

簡単なルールなら、自動でコードの修正をしてくれる機能もあるので、そういった点でも入れておくとかなり楽になります。

Swiftの静的解析ツール「SwiftLint」のセットアップ方法

Rule Directory Reference

設計とマルチモジュール編

セットアップができたところで、Pokedexのアプリの全体の設計についてを先に見ていきましょう。

Pokedexでは、アーキテクチャにMVP + CleanArchitectureを採用しています。
ef5fc34c-7f99-4689-8f98-bcee1ff62735

設計(アーキテクチャ)について

業務での開発において私が最も重要視しているのはこれと言っても過言ではありません。

設計がしっかりしたソースコードは責務分けがしっかりされているので、特定の改修を加える際に楽になります。
その上、テストを書きやすくなるので、特定の改修を行った際にデグレが起きてないかのチェックを簡単にできるというメリットもあります。

業務であれば、新規開発する期間よりもアプリをリリースしてから運用して改修する期間の方が長いことがほとんどなので、改修をより早く容易にできる様にアプリの設計を採用すべきと考えています。

例えば、PokedexでAPI通信する際のアクセスする先のURLを変更したいという要望があった場合に、DataStoreのモジュールにある、アクセス先のURLを指定している部分を変更すればいいということが何となくつかめるので、すぐに改修でき、テストも書いてあれば問題がないかどうかもすぐに分かり、安心してアップデートすることができます。

もし設計が何もない状態で全てのロジックがUIViewController上に書かれたソースコードだと、まずはそのUIViewControllerのクラスを開き、そこから特定のソースコードを探して改修することになります。
それが何の責務わけもされていない、数千行のUIViewControllerだった場合、簡単な変更をするだけでも他の部分に影響がないか調査するだけでも一苦労です。

こういう状況で、素早いリリースサイクルをこなすということは、非常に難しくなるでしょう。

今回私はMVP + CleanArchitectureを採用しましたが、それでないとダメというわけではありません。

MVC、MVVM、VIPERなどの数々のアーキテクチャがありますが、これを使えば全部において最強!みたいなのは存在せず、リリースサイクルを短くするのに、最も都合がいい物を都度選択すればいいと私は考えています。

設計はあくまで目的を実現するのに手段に過ぎないので、作りたいアプリのサイズ感や性質などに応じて適切なものを選択できる様になるといいでしょう。

アーキテクチャに関しては様々な記事が出ているので、もし詳細に知りたい場合は以下の記事を参考にするのをお勧めします。

現場で選ばれているiOSアーキテクチャ

まだMVC,MVP,MVVMで消耗してるの? iOS Clean Architectureについて

マルチモジュール化

最大の特徴はマルチモジュールで作成されているということです。

スクリーンショット 2020-04-05 7 17 15

アプリのターゲットに加えて、DataStoreDomainPresentationの三つのEmbeddef Frameworkが追加されています。

これは、先ほども上げた、アプリの構成図の黄色い四角で囲われた部分ごとに切り分けているものです。
ef5fc34c-7f99-4689-8f98-bcee1ff62735

特にこのEmbedded Frameworkに切り分けて実装せずにアプリのターゲットのみで開発することも可能なのですが、Clean Architectureの様な細かく責務が分けられた設計の場合、使うことのメリットが大きいのでこの方法を採用しました。

ビルドパフォーマンスの向上

Xcodeの差分コンパイルのが各フレームワークごとに効く様になるので、スコープが小さくなり、ビルド時間の短縮に繋がります。
ただ、Pokedexの場合はアプリ自体が大規模ではないので、そこまで恩恵は受けられてません。

不要な参照を制御

1つのターゲットに全てのファイルが入っていると、例えば、元来Presentationでは、DataStoreの処理を直接呼び出す必要がないにも関わらず、何の制限もなく呼び出すことができてしまいます。これでは、せっかく責務を切り分けたのにも関わらず、参照のルールが崩れてしまうので、図の通りにならなくなってしまいます。

自分一人だけで開発していれば、そうしない様に気を付けるだけで済みますが、複数人で開発している場合や設計に慣れていない人と一緒に開発する場合はそういうわけにはいきません。

そこをEmbedded Frameworkで切り分けることで、importしなければ、参照ができなくなるという仕様が活きてきます。

例えばPresenterでは、UseCaseの処理を呼び出すために、import Domainをしなければ、UseCaseを呼び出すことができなくなります。
スクリーンショット 2020-04-05 20 52 27

最初の例で言うならば、以下の画像の様なコードをレビューで発見した場合、怪しいコードとして心してかからなければいけないということです。
スクリーンショット 2020-04-05 20 37 42

コードレビューをした際に、こういった設計にしたがっていないコードを見落としてしまう可能性がありますが、Frameworkのターゲットを切り分けておくと、importを確認すれば、まずは参照のルールが守られているコードであるかどうかがわかるので、レビューの負担が少し減るというわけです。

あとは、少し副産物的なメリットですが、変換候補を減らすことができるという点もメリットになるでしょう。
Clean Architecutureを使用することで、ファイルや定義が非常に多くなってしまいます。(Pokedexの場合、PokemonDetailという名前を含んだ命名が32個も存在します。)

必要ない定義がFrameworkの切り分けによって少なくなるだけで、コーディングしている際のストレスがなくなります。

その32個の定義が気になる物好きの人はこちら
PokemonDetailResponse
PokemonDetailRequest
PokemonDetailAPIGatewayProvider
PokemonDetailAPIGateway
PokemonDetailAPIGatewayImpl
PokemonDetailRepositoryProvider
PokemonDetailRepository
PokemonDetailRepositoryImpl
PokemonDetailUseCaseProvider
PokemonDetailUseCase
PokemonDetailUseCaseImpl
PokemonDetailTranslatorProvider
PokemonDetailTranslator
PokemonDetailTranslatorImpl
PokemonDetailModel
PokemonDetailBuilder
PokemonDetailView
PokemonDetailViewController
PokemonDetailFavoriteButton
PokemonDetailSingleImageCell
PokemonDetailDualImageCell
PokemonDetailHeightCell
PokemonDetailWeightCell
PokemonDetailPokemonTypeCell
PokemonDetailPokemonTypeItemCell
PokemonDetailStatusCell
PokemonDetailPresenter
PokemonDetailPresenterImpl
PokemonDetailWireframe
PokemonDetailWireframeImpl
TransitToPokemonDetailWireframe

(これが変換に一気に出てくると思うとゾッとしますねw)

ライブラリの依存を明確にできる

あとは、ライブラリの依存を明確にできるということもメリットになります。
どういうことかというと、PokedexではAPI通信をする際にはAlamofireというライブラリを使用していますが、このライブラリはDataStoreの中でしか使用しません。

そういった場合に、この様に、DataStoreに対してCarthageでインストールしたライブラリをリンクしています。
スクリーンショット 2020-04-05 22 23 30

そうすると、使用する必要がないPresentationやDomainからライブラリの呼び出しを制限することができるので、ライブラリへの依存が明確になります。

コーディング編

最後に実際のコーディングに関して、最低限これはしておいて欲しいというものを紹介します。

アクセス修飾子

アクセス修飾子を正しく使うことで、より堅牢にコードを保持することができます。
全て知っておく必要がありますが、Pokedexにおいて主に使っているのはprivateinternalpublicの三つです。

他のも知っておきたい方は以下の記事を参考にしてください。
知っているようで知らないSwift5のアクセス修飾子

private

外部から参照されない(もしくはされたくない)クラスや変数には必ずprivateをつける様にしましょう。

import UIKit

final class PokemonListCell: UITableViewCell {

    @IBOutlet private weak var spriteImageView: UIImageView!

    @IBOutlet private weak var numberLabel: UILabel!

    @IBOutlet private weak var nameLabel: UILabel!

    func setData(_ data: PokemonListModel.Pokemon) {
        self.spriteImageView.loadImage(with: data.imageUrl, placeholder: Asset.mosnterball.image)
        self.numberLabel.text = "No.\(data.number)"
        self.nameLabel.text = data.name
    }
}

@IBOutletで紐づけられたpropertyはprivate付け忘れがちですが、継承して使うとかが無い限りはprivateを付けておきましょう。

import Foundation

public enum PokemonListAPIGatewayProvider {

    public static func provide() -> PokemonListAPIGateway {
        return PokemonListAPIGatewayImpl(dataStore: PokeAPIDataStoreProvider.provide())
    }
}

public protocol PokemonListAPIGateway {
    func get(completion: @escaping ((Result<PokemonListResponse, Error>) -> Void))
}

private struct PokemonListAPIGatewayImpl: PokemonListAPIGateway {

    let dataStore: PokeAPIDataStore

    func get(completion: @escaping ((Result<PokemonListResponse, Error>) -> Void)) {
        self.dataStore.request(PokemonListRequest(), completion: completion)
    }
}

PokemonListAPIGatewayImplはPokemonListAPIGatewayProviderのprovide()の中でしか呼び出す必要が無いので、privateをつけておくと、他のファイルから参照できない様になります。

public

Pokedexの場合、Embedded Frameworkを利用しているので、フレームワーク外から参照されるクラスに関してはpublicを指定する必要があります。

import DataStore
import Foundation

public enum PokemonListUseCaseProvider {

    public static func provide() -> PokemonListUseCase {
        return PokemonListUseCaseImpl(
            repository: PokemonListRepositoryProvider.provide(),
            translator: PokemonListTranslatorProvider.provide()
        )
    }
}

public protocol PokemonListUseCase {
    func get(completion: @escaping ((Result<PokemonListModel, Error>) -> Void))
}

private struct PokemonListUseCaseImpl: PokemonListUseCase {

    let repository: PokemonListRepository
    let translator: PokemonListTranslator

    func get(completion: @escaping ((Result<PokemonListModel, Error>) -> Void)) {
        self.repository.get { result in
            switch result {
            case .success(let response):
                completion(.success(self.translator.convert(from: response)))
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
}

PresentationのPokemonListPresenterImpl内で、PokemonListUseCaseが参照されているため、PokemonListUseCaseにはpublicを付ける必要があります。

PokemonListUseCaseProviderとその中のprovide()メソッドもPokemonListBuilderで呼び出されるのでpublicをつけましょう。

final修飾子

継承されないclassには必ずfinalを指定する様にしましょう。

import UIKit

final class PokemonDetailViewController: UIViewController {

}

これを付けることによって、継承の必要性が無いということを明示できるのでソースコードの理解に役立つ上に、実行時のパフォーマンス向上にもつながります。

Swiftのfinalについて

Decodable

PokeAPIはレスポンスがJsonなので、Codableという機能でレスポンスをパースすることができます。

その時に、Codableは、Encodable(オブジェクト -> Data型に変換する)とDecodable(Data型 -> オブジェクトに変換する)の両方に準拠してしまうので、今回の様なEncodableが必要が無い場合には、Decodableのみに準拠させると目的がはっきりするのでわかり易くなります。

import Foundation

public struct PokemonListResponse: Decodable {

    public let count: Int

    public let previous: String?

    public let next: String?

    public let results: [Result]
}

extension PokemonListResponse {

    public struct Result: Decodable {

        public let name: String

        public let url: String
    }
}

おわりに

最初、Pokedexを公開した時に誰も見てくれないだろうと思ってtwitterで呟いたら、予想以上の人に見てもらえたので、急いで解説記事を書きました?

至らぬところは多々ある上に、少し長く難しい内容になってしまったかもしれませんが、おそらくこの記事に書いている内容を実践して身につけていけば、業務未経験の人でもiOSエンジニアとして仕事を得るのに役立つ内容であると思います。

まだ少し書きたい事柄もあり、適度に加筆修正する予定なので、この記事についての質問も歓迎ですし、さらにはPokedexへのissue、pull requestもお待ちしております!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOS app の習得日記 (2)

(https://qiita.com/gomi_ningen/items/4e0e5bd98f08c4bcf93d)

続き

4.1 todoリスト作成までいった

```
import UIKit
import Foundation

class TODOMainViewController: UIViewController {

@IBOutlet weak var dismissButton: UIBarButtonItem!

override func viewDidLoad() {
    super.viewDidLoad()
    dismissButton.target = self
    dismissButton.action = #selector(TODOMainViewController.dismissButtonTapped(_:))
}

func dismissButtonTapped(_ sender: Any) {
    dismiss(animated: true, completion: nil)
}

}
```

新しくstoryboard と viewcontrollerを作り、
dismissButtonを接続しようとするも、

"Could not insert new outlet connection".

と出る。

(https://dev.classmethod.jp/articles/remove-xcode-derived-data/)
こんなのがでてきた
キャッシュを消せと
これでいいのか?

明日やってみよう

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む