20191201のSwiftに関する記事は25件です。

xcframeworkを作成する(第1回)

GMOアドマーケティングのT.Oです。

Xcode11でフレームワークの新しい配布形態としてxcframeworkが導入されました。
xcframeworkを使用すると、iOS実機、iOSシミュレーターなどの複数のプラットフォーム用のフレームワークを1つにまとめることができます。またアプリに組み込む際には、それぞれのプラットフォームを意識することなく利用できるようになりました1
ここではSwiftでxcframerworkを作成、利用する手順についてご紹介します。

●前提
作成するxcframeworkは、iOS10以降で利用可能なEmbedded Frameworkをまとめたものとします。
(iOS13, iPadOSにも対応しています)
開発環境としてXcode 11.0を利用しSwift5を使用します。

●xcframeworkの作成手順
1. プロジェクト作成
2. xcframework生成のためのビルド設定
3. Objective-C対応
4. テスト用のアプリ作成

今回は上記の手順のうちの
「1. プロジェクト作成」
「2. xcframework生成のためのビルド設定」
についてまとめます。

1.プロジェクト作成

まずフレームワーク用のテンプレートを選択してプロジェクトを作成します。
Xcodeで「File→New→Project..」メニューを選び、テンプレートの選択画面で「Cocoa Touch Framework」を選択します。
xcframework_p1.png

新規プロジェクトのオプション選択画面が表示されるので、Product Nameなどの設定を行います。
xcframework_p2.png
iOS10以降で利用可能としたいので、PROJECTのDeployment→iOS Deployment Targetで"iOS10.0"を選択します。
xcframework_p2-2.png
xcframeworkを作成するため、Build Options→Build Libraries for DistributionでYesを選択します。
xcframework_p3.png
次に「New File」を選択し、テンプレート選択画面で「Cocoa Touch Class」を選び
フレームワークの処理をSwiftで記述します。

ここでは以下のように文字をコンソール出力することにします。

Ore.swift
import UIKit

public class Ore: NSObject {
    public func oreMethod(){
        print("Hello Ore XCFramework");
    }
}

ビルドターゲットをフレームワーク(この例だとOreXCFramework)に変更します。Product→Buildを選択しビルドします。ビルドエラーがないことを確認します。

2.xcframeworkのためのビルド設定

iOS実機、iOSシミュレータ用のフレームワークをビルドして1つのxcframeworkにまとめるためのビルド設定を行います。

「File→New→Target」で新規ターゲットのテンプレート選択画面で「Cross-platform→Aggregate」を選びます。
xcframework_p4.png
Product Nameを指定します。ここでは例として"Aggregate"と指定します。
xcframework_p5.png
次にTARGETSから作成した"Aggregate"を選択した状態で「Build Phases」を選び、「+」を選択して「New Run Script」を選びます。
xcframework_p6.png
Run Scriptが作成されます。
xcframework_p7.png

Run Scriptの"#Type a script or drag file from your workspace to insert its path"と記述されている部分に以下のxcframework出力用スクリプトを貼り付けます。
このスクリプトを実行するとフレームワークのプロジェクト以下にOutputディレクトリを作成します。そしてOutputディレクトリ以下にxcframework作成のための中間ファイル、archiveファイル、xcframeworkを出力します。

# 出力先ディレクトリ(プロジェクトの直下)
OUTPUT_DIR=${PROJECT_DIR}/Output

# 中間ファイルの出力先ディレクトリ
DERIVED_DIR=${OUTPUT_DIR}/${CONFIGURATION}-derived

# archiveの出力先ディレクトリ
ARCHIVE_DIR=${OUTPUT_DIR}/${CONFIGURATION}-archive

# xcframeworkの出力先ディレクトリ
XCFRAMEWORK_DIR=${OUTPUT_DIR}/${CONFIGURATION}-xcframework

# 出力先ディレクトリ削除
rm -rf ${OUTPUT_DIR}

# 中間ファイルの出力先ディレクトリ作成
mkdir -p ${DERIVED_DIR}

# アーカイブファイルの出力先ディレクトリ作成
mkdir -p ${ARCHIVE_DIR}

# xcframeworkの出力先ディレクトリ作成
mkdir -p ${XCFRAMEWORK_DIR}

# iOS実機用のarchiveファイル
ARCHIVE_FILE_IOS=${ARCHIVE_DIR}/ios.xcarchive
echo "ARCHIVE_FILE_IOS:${ARCHIVE_FILE_IOS}"

# iOSシミュレータ用のarchiveファイル
ARCHIVE_FILE_IOS_SIMULATOR=${ARCHIVE_DIR}/iossimulator.xcarchive
echo "ARCHIVE_FILE_IOS_SIMULATOR:${ARCHIVE_FILE_IOS_SIMULATOR}"

# iOS実機用のarchiveファイル作成
xcodebuild archive -scheme ${PROJECT_NAME} -destination="iOS" -archivePath $ARCHIVE_FILE_IOS -derivedDataPath $DERIVED_DIR -sdk iphoneos SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES

# iOSシミュレータ用のarchiveファイル作成
xcodebuild archive -scheme ${PROJECT_NAME} -destination="iOS Simulator" -archivePath $ARCHIVE_FILE_IOS_SIMULATOR -derivedDataPath $DERIVED_DIR -sdk iphonesimulator SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES

# xcframework作成
xcodebuild -create-xcframework -framework $ARCHIVE_FILE_IOS/Products/Library/Frameworks/${PROJECT_NAME}.framework -framework $ARCHIVE_FILE_IOS_SIMULATOR/Products/Library/Frameworks/${PROJECT_NAME}.framework -output $XCFRAMEWORK_DIR/${PROJECT_NAME}.xcframework

# xcframeworkの出力ディレクトリをFinderで開く
open ${XCFRAMEWORK_DIR}

ビルドターゲットをAggregateに変更します。そしてProductメニュから→Buildを選択して
ビルドします。
xcframework_p8.png
ビルドが成功したら生成されたxcframeworkをFinderやtreeコマンドなどで確認します。
実機用フレームワーク(ios-armv7_arm64以下)とシミュレータ用フレームワーク(ios-i386_x86_64-simulator以下)が両方とも含まれている状態になっていればここまでの手順は完了です。

%tree OreXCFramework.xcframework
OreXCFramework.xcframework
├── Info.plist
├── ios-armv7_arm64
│   └── OreXCFramework.framework
│       ├── Headers
│       │   ├── OreXCFramework-Swift.h
│       │   └── OreXCFramework.h
│       ├── Info.plist
│       ├── Modules
│       │   ├── OreXCFramework.swiftmodule
│       │   │   ├── arm.swiftdoc
│       │   │   ├── arm.swiftinterface
│       │   │   ├── arm64-apple-ios.swiftdoc
│       │   │   ├── arm64-apple-ios.swiftinterface
│       │   │   ├── arm64.swiftdoc
│       │   │   ├── arm64.swiftinterface
│       │   │   ├── armv7-apple-ios.swiftdoc
│       │   │   ├── armv7-apple-ios.swiftinterface
│       │   │   ├── armv7.swiftdoc
│       │   │   └── armv7.swiftinterface
│       │   └── module.modulemap
│       ├── OreXCFramework
│       └── _CodeSignature
│           └── CodeResources
└── ios-i386_x86_64-simulator
    └── OreXCFramework.framework
        ├── Headers
        │   ├── OreXCFramework-Swift.h
        │   └── OreXCFramework.h
        ├── Info.plist
        ├── Modules
        │   ├── OreXCFramework.swiftmodule
        │   │   ├── i386-apple-ios-simulator.swiftdoc
        │   │   ├── i386-apple-ios-simulator.swiftinterface
        │   │   ├── i386.swiftdoc
        │   │   ├── i386.swiftinterface
        │   │   ├── x86_64-apple-ios-simulator.swiftdoc
        │   │   ├── x86_64-apple-ios-simulator.swiftinterface
        │   │   ├── x86_64.swiftdoc
        │   │   └── x86_64.swiftinterface
        │   └── module.modulemap
        ├── OreXCFramework
        └── _CodeSignature
            └── CodeResources

参考

執筆にあたり以下を参考にさせていただきました。
Binary Frameworks in Swift - WWDC 2019 - Videos - Apple Developer
いけだや技術ノート Xcode 11で導入されるxcframeworkのディレクトリ構造

明日は、「xcframeworkを作成する(第2回)」です。
引き続き、GMOアドマーケティング Advent Calendar 2019をお楽しみください!


  1. Xcode10までは、各プラットフォーム用のフレームワークを1つのファイルにまとめることはできていたのですが、それをアプリで利用する際に必要なプラットフォームに適合したフレームワークのみを切り出すためのスクリプトを実行する必要があり、手順がやや煩雑になっていました。 

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

Working with UI Controlsチュートリアルが動かない件の対処

Working with UI Controlsチュートリアルが動かない件の対処

SwiftUIのユーザ入力画面チュートリアルWorking with UI Controlsですが、チュートリアルの通り実装を行っても、ProfileHome.swiftのPreview(Canvas)が動きません。

以下の2点を解消することで動作させることができます。

画面が表示されない

.onAppearにおいて、初期化されていないuserDataにアクセスしているため。

初期化すればよいので、以下のようにしてEnvironmentObjectを初期化しましょう。

ProfileHost.swift
struct ProfileHost_Previews: PreviewProvider {

    static var previews: some View {
        ProfileHost()
+        .environmentObject(UserData())
    }
}

サマリー画面が表示されない

Canvas環境ではeditModeの環境変数が初期化されていないため。

これはXCode側の問題ですが、Appleが本件に対処するまでは、以下のように試験対象ビューにラッパーを噛ませて、ラッパー側でeditModeの実体を宣言することで対応できます。

ProfileHost.swift
+
+struct PreviewWrapperView: View {
+    @State var editMode : EditMode = .inactive
+
+    var body: some View {
+        return ProfileHost().environment(\.editMode, $editMode)
+    }
+}
+
struct ProfileHost_Previews: PreviewProvider {

    static var previews: some View {
-        ProfileHost()
+        PreviewWrapperView()
    }
}

最終形

上記2点を併せたソースがこちらです。

ProfileHost.swift
struct PreviewWrapperView: View {
    @State var editMode : EditMode = .inactive

    var body: some View {
        return ProfileHost().environment(\.editMode, $editMode)
    }
}

struct ProfileHost_Previews: PreviewProvider {

    static var previews: some View {
        PreviewWrapperView()
        .environmentObject(UserData())
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SwiftでシンプルなRSSリーダーを作る(Yahooニュース編)

SwiftでシンプルなRSSリーダーを作る

暇だったのでYahooニュースの記事を閲覧できるRSSリーダーを作ってみました。
作ってみてわかりましたが、RSSリーダーの作成はSwift初心者の学習に有効だと思います。
いろんなサイトがRSSを公開していますし、URLを叩いて情報を取ってきて加工してtableViewに表示するというiOSの基本が学べます。

<捕捉>そもそもRSSって何?

RSSとは「Really Simple Syndication」の略語であり、ニュースや記事のタイトル、リンクなどの内容を要約して配信する
仕組みのことです。RSSは記事取得用のurlが用意されており、そのurlを叩くとXML形式で記事の内容を返してくれます。

用件

  • RSSを取得して「タイトル」「日付」「サムネイル画像」の3つをtableViewに表示する。
  • 記事をタッチして該当のニュースをwebViewで表示する
  • タブメニューで記事の種類を選択できるようにする

完成イメージ
スクリーンショット 2019-11-23 15.50.50.png スクリーンショット 2019-11-23 15.56.58.png

開発環境

エディタ・言語など

  • Xcode11.1
  • Swift5.1
  • cocoaPods1.5.3

使用ライブラリ

RSSから記事を取得する

まずRSSから記事を取得してtableViewに渡す処理を作っていきます。
今回使用するのはYahoo!ニュースのRSSです。
https://headlines.yahoo.co.jp/rss/list

RSSはXML形式のレスポンスを返します。
XMLの扱うために以下2点の方法を考えました。
* 1.Swift標準のXMLParserを使用する。
* 2.XMLをJsonに変換し、JsonをCodableでモデル化して使用する。

それぞれメリットデメリットはありますが、Jsonの方が色々とこねくり回しやすいので、今回は2番目の方法で実装します。

XMLをJsonに変換する

XMLからJsonへの変換はAPIを通して行いました。
以下のサイトがとても便利でした。
[rss to json]https://rss2json.com/#rss_url=http%3A%2F%2Ffeeds.twit.tv%2Fbrickhouse.xml

rssToJsonでは、RSSのurlを入力するだけでJsonに変換したレスポンスを表示し、Json取得用のAPIを作成してくれます。

rsstojson

APIを用意できたら記事を取得する準備をしていきましょう。
やることは以下の2つです。
* レスポンスを変換するモデルを作成する。
* APIを叩いて記事を取得する。

レスポンス変換用のモデルを用意する

APIのレスポンス変換用にモデルを作成します。

まずは元のJsonの構造を理解しましょう。

{
    "status": "ok",
    "feed": {
        "url": "https://news.yahoo.co.jp/pickup/rss.xml",
        "title": "Yahoo!ニュース・トピックス - 主要",
        "link": "https://news.yahoo.co.jp/",
        "author": "",
        "description": "Yahoo! JAPANのニュース・トピックスで取り上げている最新の見出しを提供しています。",
        "image": ""
    },
    "items": [
        {
            "title": "官邸「譲らない」GSOMIA折衝",
            "pubDate": "2019-11-23 02:28:19",
            "link": "https://news.yahoo.co.jp/pickup/6343284",
            "guid": "yahoo/news/topics/6343284",
            "author": "",
            "thumbnail": "",
            "description": "",
            "content": "",
            "enclosure": {
                "link": "https://s.yimg.jp/images/icon/photo.gif",
                "type": "image/gif",
                "length": 133
            },
            "categories": []
        },

この構造を踏まえてモデルを作成します。
以下のようになりました。

/// RSSから取得する記事リスト
struct ArticleList: Codable {
    let status: String
    let feed: Feed
    let items: [Item]
}
/// フィード
struct Feed: Codable {
    let url: String
    let title: String
    let link: String
    let author: String
    let description: String
}
/// 記事詳細
struct Item: Codable {
    let title: String
    let pubDate: String
    let link: String
    let guid: String
}

使わないデータや空のデータはモデルに含めなくても問題ありません。
これでレスポンス変換用のモデルができたので、APIを叩いて記事のJsonを取ってきましょう。

APIを叩いて記事を取得する

引数にURLを渡すとそのURLから記事を取得する関数を作ります。

/// RSS取得用クラス
class RssClient {

    /// 記事の一覧を取得します。
    /// - Parameter urlString: 取得元RSSのurl
    /// - Parameter completion: 完了時の処理
    static func fetchItems(urlString: String, completion: @escaping (Result<[Item], Error>) -> ()) {

         // URL型に変換できない文字列の場合は弾く
        guard let url = URL(string: urlString) else {
            completion(.failure(NetworkError.invalidURL))
            return
        }

        let task = URLSession.shared.dataTask(with: url, completionHandler: { data, response, error in

            if let error = error {
                completion(.failure(error))
                return
            }

            guard let data = data else {
                completion(.failure(NetworkError.unknown))
                return
            }

            let decoder = JSONDecoder()
            guard let articleList = try?decoder.decode(ArticleList.self, from: data) else {
                completion(.failure(NetworkError.invalidResponse))
                return
            }
            completion(.success(articleList.items))
        })
        task.resume()
    }
}

上記のコードでは3つのことをやっています。

  • APIのURLをString型からURL型に変換する。
  • URLSession.shared.dataTaskに渡して結果を受け取る。
  • 受け取った結果をJson型からArticleList型に変換する

ポイントは関数のcompletionクロージャにResult型の引数を使っていることです。

completion: @escaping (Result<[Item], Error>) -> ()

こいつを引数に渡してあげると、Result型のcase次第で成功と失敗を同じクロージャで使い分けることができます。

/// A value that represents either a success or a failure, including an
/// associated value in each case.
public enum Result<Success, Failure> where Failure : Error {

    /// A success, storing a `Success` value.
    case success(Success)

    /// A failure, storing a `Failure` value.
    case failure(Failure)

成功ならsuccessで、失敗ならfailureでcompletionを実行し、
実行時の引数には記事取得結果であるItems型と、エラーが起きたときのError型を渡してやります。

// 成功
completion(.success(articleList.items))

// 失敗
if let error = error {
    completion(.failure(error))
    return
}

これでTableViewに記事を表示する準備ができました。

取得した記事をTableViewに表示する

関数を用意できたのでTableViewを表示するクラス側で呼び出してあげます。

/// ホーム画面
class NewsViewController: UITableViewController, IndicatorInfoProvider {

    /// 記事一覧
    private var items: [Item] = [] {
        didSet {
            tableView.reloadData()
        }
    }

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

        RssClient.fetchItems(urlString: self.newsType.urlStr, completion: { (response) in
            switch response {
            case .success(let items):
                DispatchQueue.main.async() { [weak self] in
                    guard let me = self else { return }
                    me.items = items
                }
            case .failure(let err):
                print("記事の取得に失敗しました: reason(\(err))")
            }
        })
    }

viewWillAppearで記事を取得し、記事一覧のプロパティに記事をセットしたらtableViewのリロードが走るようにしています。

/// 記事一覧
private var items: [Item] = [] {
    didSet {
        tableView.reloadData()
    }
}

Result形で定義していたcompetionクロージャは、Result<[Item], Error>を引数として、成功時の処理と失敗時の処理をswitchで分けて実行することができています。

RssClient.fetchItems(urlString: self.newsType.urlStr, completion: { (response) in
    switch response {
    // 成功
    case .success(let items):
        DispatchQueue.main.async() { [weak self] in
            guard let me = self else { return }
                me.items = items
            }
    // 失敗
    case .failure(let err):
        print("記事の取得に失敗しました: reason(\(err))")
    }
})

これで取得した記事をTableViewに表示できるようになりました。

サムネイル画像を取得する

RSSから記事は取得できました。
しかしYahooニュースのRSSにはサムネイル画像用のurlが用意されていません。
そこで記事のHTMLからサムネイル画像のurlを抽出し、そのurlからUIImageを取得してセルに表示することにしました。

やることは以下の2つです。
* 記事のURLからHTMLを取得する。
* サムネイル画像用のURLを抽出する。

記事のURLからHTMLを取得する

まずはサムネイルを取得したい記事のURLからHTMLのソースを取得してきます。

/// 記事の画像のurlを取得します
/// - サムネイル表示のために用意
/// - Warning: URLから取得先のHTML全部取ってきてサムネだけ抜き出した上で画像のURL返してるから非常に冗長。
///            かつロードにも時間がかかるのでキャッシュに持たせるとかして修正を検討してください。
///
/// - Parameter urlStr: 画像取得先のurl
/// - Parameter completion: 完了後の処理
static func fetchThumnImgUrl(urlStr: String, completion: @escaping (Result<URL, Error>) -> ()) {
    // URL型に変換できない文字列の場合は弾く
    guard let targetURL = URL(string: urlStr) else {
        completion(.failure(NetworkError.invalidURL))
        return
    }
    do {
        // 入力したURLのページから、HTMLのソースを取得します。
        let sourceHTML = try String(contentsOf: targetURL, encoding: String.Encoding.utf8)
    catch {
        completion(.failure(error))
    }
}

SwiftのString型は指定したURLからHTMLを取得してエンコードすることができるみたいです。

スクリーンショット 2019-11-23 18.54.21.png

TODO:HTML全部取ってくるのは重いし冗長なのでサムネイルだけ取得するように直したい

今回は記事のHTMLを全て取ってきているんですが、欲しいのはサムネイル画像だけなので冗長な作りになってしまいました。
セルの生成する度に毎度記事のHTMLを全て取得していると、ロードに時間がかかってアプリの操作感が悪くなります。
今回この問題は解決できませんでしたが、部分的に必要な情報だけ取得するように直すのが今後の課題になりました。

HTMLからサムネイル画像を抽出する

HTMLが取得できたらHTMLReaderを使ってサムネイル画像だけ抜き出しましょう。

import HTMLReader

/// RSS取得用クラス
class RssClient {
    ...

    /// 記事の画像のurlを取得します
    /// - サムネイル表示のために用意
    /// - Warning: URLから取得先のHTML全部取ってきてサムネだけ抜き出した上で画像のURL返してるから非常に冗長。
    ///            かつロードにも時間がかかるのでキャッシュに持たせるとかして修正を検討してください。
    ///
    /// - Parameter urlStr: 画像取得先のurl
    /// - Parameter completion: 完了後の処理
    static func fetchThumnImgUrl(urlStr: String, completion: @escaping (Result<URL, Error>) -> ()) {
        // URL型に変換できない文字列の場合は弾く
        guard let targetURL = URL(string: urlStr) else {
            completion(.failure(NetworkError.invalidURL))
            return
        }
        do {
            // 入力したURLのページから、HTMLのソースを取得します。
            let sourceHTML = try String(contentsOf: targetURL, encoding: String.Encoding.utf8)

            let html = HTMLDocument(string: sourceHTML)
            // サムネイルの入ったエレメントを抜き出します。
            let htmlElement = html.firstNode(matchingSelector: "p[class^=\"tpcHeader_thumb_img\"]")
            // エレメントからstyleだけ抽出します。
            guard let style = htmlElement?.attributes["style"] else {
                completion(.failure(AppalicationError.unknown))
                return
            }
            // 無駄な文字列を削除して整形します。
            let imageUrlStr: String = {
                let startIndex = style.index(style.startIndex, offsetBy: 23)
                let endIndex = style.index(style.endIndex, offsetBy: -3)
                return String(style[startIndex..<endIndex])
            }()

            guard let imageUrl = URL(string: imageUrlStr) else {
                completion(.failure(NetworkError.invalidURL))
                return
            }

            completion(.success(imageUrl))
        }
        catch {
            completion(.failure(error))
        }
    }
}

HTMLReaderはSwift版のHTMLパーサーライブラリであり、HTMLドキュメントから欲しいタグの情報を抽出することができます。
まずStringのHTMLからHTMLドキュメントを作ります。
僕はHTMLもCSSもよく分からないんですが、どうやらCSSSelectorというものを検索できるみたいです。
今回はtpcHeader_thumb_imgとやらにサムネイルのurlがあったのでそこだけ取得することにしました。

<div class="tpcHeader_thumb">
    <p class="tpcHeader_thumb_img" style="background-image: url('https://giwiz-tpc.c.yimg.jp/q/iwiz-tpc/images/tpc/2019/11/19/1574165035_20191113-00000622-san-000-view.jpg');"></p>
</div>

該当の箇所↓
HTMLは正しい用語が分からないのでコメントの内容間違ってたりしたらすみませんmm

// 文字列のHTMLからHTMLDocumentを生成します。(ここからHTMLReaderの機能)
let html = HTMLDocument(string: sourceHTML)
// サムネイルの入ったエレメントを抜き出します。
let htmlElement = html.firstNode(matchingSelector: "p[class^=\"tpcHeader_thumb_img\"]")
// エレメントからstyleだけ抽出します。
guard let style = htmlElement?.attributes["style"] else {
    completion(.failure(AppalicationError.unknown))
    return
}

これでサムネイル画像取得用の関数が用意できました。
そしたらこの画像をTableViewに表示していきましょう。

取得したサムネイル画像をTableViewに表示する

サムネイルの取得に成功したらSDWebImageを使ってURLから画像をロードします。

RssClient.fetchThumnImgUrl(urlStr: link, completion: { response in
    switch response {
    case .success(let url):
        SDWebImageManager.shared.loadImage(with: url,
                                           options: .progressiveLoad,
                                           context: nil,
                                           progress: nil,
                                           completed: { (image, data, error, cache, finished, url) in
                                            articleCell.articleImage.image = image
        })
    case .failure(let err):
        print("HTMLの取得に失敗しました: reason(\(err))")
    }
})

SDWebImageは画像の取得、キャッシュの保存、キャッシュからの画像の読み出し、UIImageへのセットなどをよしなにやってくれるライブラリです。
SDWebImageManager.shared.loadImageを使って画像をDLすると指定したURLから画像を取得、キャッシュに保存してくれます。キャッシュに既にデータがあれば次回DL時はキャッシュから画像を呼び出しくれるという神仕様です。

ここまででRSSから「タイトル」「日付」「サムネイル」を取得し、セルに表示できるようになりました。

記事をタッチして該当のニュースをwebViewで表示する

今度はセルをタッチして記事の詳細を表示します。
RSSでは各記事の本文まで取得できないので、こっちはWebViewを使って作ることにしました。

まずはWebKitをインポートし、WebView表示用のクラスを作ります。

import UIKit
import WebKit

class DetailWebViewController: UIViewController {

    // MARK: Properties

    /// 記事表示用webView
    private let wkWebView = WKWebView()
    /// 読み込むURL
    private var urlStr: String?

    // MARK: Initializer

    init(urlStr: String) {
        self.urlStr = urlStr
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // MARK: LifeCycle

    override func viewDidLoad() {
        super.viewDidLoad()
        // TODO: WebView適当すぎるのでプログレスバーとか戻るボタンとか付けたい
        wkWebView.frame = view.frame
        wkWebView.navigationDelegate = self
        wkWebView.uiDelegate = self
        wkWebView.allowsBackForwardNavigationGestures = true
        let url = URLRequest(url: URL(string: urlStr!)!)
        wkWebView.load(url)
        view.addSubview(wkWebView)
    }
}

extension DetailWebViewController: WKNavigationDelegate {

}

extension DetailWebViewController: WKUIDelegate {

}

ViewControllerのViewと同じ広さのWebViewのインスタンスを作成し、URLを渡して読み込んでもらうだけのシンプルなクラスです。
(プチTODO:ちょっとシンプルすぎるのでプログレスビューとか戻るボタンとか今後付けたい)

WebView表示用クラスができたら、セルのタッチで画面遷移するように実装します。

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let link = items[indexPath.row].link
    let vc = DetailWebViewController(urlStr: link)
    self.navigationController?.pushViewController(vc, animated: true)
}

これで記事をWebViewで閲覧できるようになりました。

タブメニューで記事の種類を選択できるようにする

本家Yahooニュースしかりグノシーしかり、ニュースアプリ大体画面上部にタブメニューを持っており、タブの切り替えでニュースの分類を切り替えるように作られています。
というわけでトピックの数だけタブメニューを作成するように実装していきます。

↓ここの部分
スクリーンショット 2019-12-01 17.16.47.png

タブメニューを作成できるライブラリはいくつかありますが、今回はXLPagerTabStripというライブラリを採用してみました。

  • VCを渡すだけでいいから簡単
  • ライブラリをインポートしたクラスからnavigationBarにアクセスできる。(他のライブラリはできないやつもあるらしい)

この辺りが採用の決め手です。

それではタブメニューを作っていきます。
やることは以下の3つです。

  • XLPagerTabStripをインストール
  • タブメニュー実装用親クラスを作成する
  • タブメニューの中身になる子VCを作成する

XLPagerTabStripをインストール

CocoaPods、もしくはCarthageを使ってインポートできます。
やり方は本家githubから確認できます。
https://github.com/xmartlabs/XLPagerTabStrip#cocoapods

タブメニュー表示用の親クラスを作る

インストールが完了したら、タブメニューを表示するための親クラスを作っていきます。
今回はstoryboardを使いました。

タブメニューを実装する場合、親クラスのstoryboardに以下の2点を持つ必要があります。

  • タブメニューの置き場となるCollectionView
  • 各ページのVCが配置されるScrollView

各パーツの設定は詳しく説明してくださってる記事があるのでそちらを参考にしてください。(断じて書くのがめんどくさいわけではない。)
XLPagerTabStripの使い方とカスタマイズ

storyboardが用意できたら、今度はコードで必要な設定を書いていきます。
難しいことはなかったです。ポイントは3つあります。

  • ButtonBarPagerTabStripViewControllerを継承したクラスを作る
  • viewControllersメソッドをoverrideしてその返り値にメニューに詰め込みたいViewControllerを渡してあげる
  • super.viewDidLoad()より上にタブメニューのレイアウト設定を書く

実際に書くとこんな感じになります。

import XLPagerTabStrip

/// ページメニュー用ViewController
class PageMenuViewController: ButtonBarPagerTabStripViewController {

    // MARK: LifeCycle

    override func viewDidLoad() {
        setButtonBar()
        super.viewDidLoad()
        navigationItem.title = "Yahoo!ニュース"
    }

    /// メニューに表示するVCを渡す
    override func viewControllers(for pagerTabStripController: PagerTabStripViewController) -> [UIViewController] {
        return createNewsViewController()
    }

    // MARK: Private Function

    /// タブメニューのレイアウトを設定します
    /// - Warning: 必ずsuper.viewDidLoad()の上で呼び出してください。
    private func setButtonBar() {
        settings.style.buttonBarBackgroundColor = .clear
        settings.style.selectedBarBackgroundColor = .orange
        settings.style.buttonBarMinimumLineSpacing = 2
    }
}

タブメニューの中身になる子VCを作成する

今度はメニューに表示する子VCを作ります。
子VC側はstoryboardやxibの設定は必要ありません。
基本的には上で実装したクラスにVCを渡すだけなんですが、2点やることがあるので説明します。

  • IndicatorInfoProviderプロトコルにVCを適合させる
  • indicatorInfoメソッドにタブメニューの情報を追加する
import XLPagerTabStrip

/// ホーム画面
class NewsViewController: UITableViewController, IndicatorInfoProvider {

    ...

    /// タブメニュー編集用インスタンス
    private var itemInfo = IndicatorInfo(title: "タブ名")

    // MARK: - IndicatorInfoProvider

    func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo {
        return itemInfo
    }
}

IndicatorInfoProviderプロトコルに準拠するとindicatorInfoメソッドの実装を要求されます。
このindicatorInfoに渡すのがタブメニューのタイトルや各トピックのごとにメニューに表示したい画像などのタブメニューの情報です。

IndicatorInfoのstructを見てみるとどんな設定値が用意されてるか分かります。

public struct IndicatorInfo {

    public var title: String?
    public var image: UIImage?
    public var highlightedImage: UIImage?
    public var accessibilityLabel: String?
    public var userInfo: Any?

    ...

まとめ

ここまでの紹介した手順でRSSリーダーというか、Yahooニュースもどきができました。
コミットの単位とかめちゃくちゃですが一応githubにコードを載せてます。
https://github.com/kawano108/YahooNewsMirror

RSSから記事を取ってきて表示するだけの予定でしたが、サムネイルのURLがレスポンスに入ってなかったりと結構てこずりました。
けどHTMLから取得した情報をSwiftで使う方法がわかったので良かったです。
YahooニュースのRSSは結構スカスカだったんですが、はてなのRSSは充実してると後から知りました。
今度ははてなのRSSを使って何か作ってみたいです。

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

【iOS 13】アプリアイコンをダークモード対応させることは可能なのか検証してみる!!

はじめに

皆さんはダークモード対応されていますか?
だいぶ対応したアプリが増えてきたように感じます。
対応するのは大変(主に工数が)ですがやっぱり対応したら謎の満足感あります。

私は個人開発のアプリでは対応しましたが,
まだ業務で担当している案件では対応できていないです。

Xcode では,画像や色のアセットでライト/ダークモードで
それぞれ使う色,画像を設定できるのですごく楽ですよね。

アプリアイコンはダークモード対応できないのかな??
と思って調べてみました。

ちなみにアプリアイコンは Xcode にダークモードなどの設定はないですね。
01_img_AppIcon_no_settings.png

YUMEMI.swift #5 でトークした内容になります。
https://yumemi.connpass.com/event/153206/

アプリアイコンを変更する(iOS 10.3 ~)

アプリアイコンの変更や名前取得などは iOS 10.3 から使えます。

// hogeという名前のアプリアイコンを設定する
if #available(iOS 10.3, *) {
    UIApplication.shared.setAlternateIconName("hoge") { error in
        if let error = error {
            print(error.localizedDescription)
        }
    }
}

元のアイコンに戻すときは nil をセットすれば良い。

// 元のアイコンに戻す
if #available(iOS 10.3, *) {
    UIApplication.shared.setAlternateIconName(nil) { error in
        if let error = error {
            print(error.localizedDescription)
        }
    }
}

注意点として下記があります。

  • info.plist に利用するアイコン情報を記載する
  • ViewController に記載する
  • メインスレッドで実行する
  • 変更するアプリアイコンは xcassets を利用せず直接導入する

これらのコードとライト/ダークモードが切り替わった際に呼ばれる,
メソッドを override して,Appearance が切り替わった際に
それぞれのモードに対応したアプリアイコンを変更させようとしてみます。

実装

実装環境

  • Xcode 11.2.1
  • macOS Catalina 10.15.1
  • iOS 13 and later

サンプルコードは GitHub に用意しました。
必要に応じて参照ください。
https://github.com/MilanistaDev/AppIconCorrespondingToDarkMode
QRCode_APPICON_Darkmode.png

本実装

ライトモードで設定するアプリアイコン画像をセット

ライトモードで設定するアプリアイコンを xcassetsAppIcon で設定します。
いわゆるいつものアプリアイコンセット作業です。
02_img_AppIcon.png

ダークモードで設定するアプリアイコン画像を準備

ダークモード用アプリアイコンの画像を用意します。
@2x@3x でそれぞれ 120x120180x180 ピクセルです。
名前は AppIcon-Dark としました。
03_img_darkmode_icons.png

用意した画像をプロジェクトに追加

先にあった通り Assets.xcassets で AppIcon を追加しても
ダークモード用の設定ができません。
なので直接プロジェクトに追加します。
04_img_AddedDakModeAppIcon.png

info.plist を編集

追加した画像を利用するために info.plist を編集する必要があります。
下記コードを追加します。

<key>CFBundleIcons</key> 
<dict> 
    <key>CFBundlePrimaryIcon</key> 
    <dict> 
        <key>CFBundleIconFiles</key> 
        <array> 
            <string>AppIcon</string> 
        </array> 
        <key>UIPrerenderedIcon</key> 
        <false/> 
    </dict> 
    <key>CFBundleAlternateIcons</key> 
    <dict> 
        <key>AppIcon-Dark</key> 
        <dict> 
            <key>CFBundleIconFiles</key> 
            <array> 
                <string>AppIcon-Dark</string> 
            </array> 
            <key>UIPrerenderedIcon</key> 
            <false/> 
        </dict> 
    </dict> 
</dict>

見やすいプロパティリストで見ると下記のようになります。
Primary Icon の方がライトモードのアプリアイコン設定で,
黄色の四角の部分が追加したダークモード用アプリアイコン画像のファイル名です。
05_img_infoplist_pl.png

ライト/ダークモードの設定でアプリアイコンを切り替える

ViewControllerでアプリアイコンを変更するコードを書く必要があるので,
例えば ViewController.swift に下記のようなコードを書きます。
このコードで Appearance が変更された際に,
現在のモードを判別してアプリのアイコンを切り替える処理が実行されます。

ViewController.swift
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)

    if self.traitCollection.userInterfaceStyle == .dark {
        // ダークモード用のアプリを設定する
        UIApplication.shared.setAlternateIconName("AppIcon-Dark") { error in
            if let error = error {
                print(error.localizedDescription)
            }
        }
    } else {
        // nilをセットしデフォルトのアプリアイコン画像に変更
        UIApplication.shared.setAlternateIconName(nil) { error in
            if let error = error {
                print(error.localizedDescription)
            }
        }
    }
}

が,しかし・・・
コントロールセンターでライト/ダークモードを切り替えると
エラー出力部分を通過することがわかります。

エラー内容は下記です。

The operation was cancelled.

RPReplay_Final1574672398.gif

いろいろ調べて遅延実行するとうまくいくとのことで
下記コードで包んであげると・・・

DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
}

ブレイクポイント止めるとライト/ダークモード切り替え時に,
ちゃんとアプリアイコン変更ダイアログが表示されます。
しかし,ブレイクポイントを貼らなければ状況は変わりませんでした。

ライト->ダーク ダーク->ライト
RPReplay_Final1574673071.gif RPReplay_Final1574673262.gif

結果

ライトモードとダークモードの変更時にアプリアイコンを変更することはできなさそう。
この変更がユーザのアプリ内でのアクションではなく,
アプリ外の操作(iOSによるもの)のため,おそらくキャンセルされてしまうのかなと考えました。
(ユーザが起こしているアクションには違いないのですが?)

では,ユーザが起こす,アプリ内のトリガーで今のモードを判別して,
アプリアイコン画像をセットする処理を書くとちゃんと設定できるのかな?

ユーザが選択可能にする

というわけで,よくある仮の設定画面を用意し,
アプリアイコン画像を現在の Appearance に合わせる,的な
処理を書いてみます。

仮の設定画面はこんな感じで適当に準備しました。
最初のセクションの2番目のセルをタップして
アプリアイコン画像を変える処理を書いてみます。

ライトモード ダークモード
Settings_LIGHT.PNG Settings_Dark.PNG

UITableView の delegate メソッドのコード。

SettingsViewController.swift
extension SettingsViewController: UITableViewDelegate {

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        self.tableView.deselectRow(at: indexPath, animated: true)
        if indexPath.section == 0 && indexPath.row == 1 {
            // セルがタップされた際にアプリアイコン画像を変更
            self.matchAppIcon()
        }
    }
}

現在の Appearance の状態によってライトモード,ダークモード用の
アプリアイコン画像を設定するコードはこんな感じで書いてみました。

SettingsViewController.swift
/// Match the App Icon to the current Appearance
private func matchAppIcon() {
    if self.traitCollection.userInterfaceStyle == .dark {
        // ダークモード用のアプリを設定する
        UIApplication.shared.setAlternateIconName("AppIcon-Dark") { error in
            if let error = error {
                print(error.localizedDescription)
            }
        }
    } else {
        // nilをセットしデフォルトのアプリアイコン画像に変更
        UIApplication.shared.setAlternateIconName(nil) { error in
            if let error = error {
                print(error.localizedDescription)
            }
        }
    }
}

セルをタップしてみたところ,ダイアログが表示され,
アプリアイコン画像を変更することができました。

ライトモード ダークモード
Success_Light.PNG Success_Dark.PNG

実際の動きは下記のようになります。
ライトモード,ダークモードに切り替わった際にはアプリアイコンはそのまま,
セルのタップをした際に初めてダイアログが出てアプリアイコンが変更されるという感じです。

ライト=>ダーク ダーク=>ライト
RPReplay_Final1574677144.gif RPReplay_Final1574677169.gif

おわりに

コントロールセンターなどでライトモード,ダークモードを切り替える際に
それぞれのモードに対応したアプリアイコン画像を変更することはできないっぽい。

セルのタップやボタンタップなどユーザが起こすアクションをトリガーとすると,
変更した旨のダイアログが出て,各モードに対応したアプリアイコンに変更することは可能でした。

ライトモード,ダークモード切り替え時にアプリアイコン変更できるぜ,
もっとこうした方がクレバーですよー等ありましたらご教示いただければ幸いです。

ご覧いただきありがとうございました!

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

SwiftUIでViewを画像としてUIActivityを利用してSNSに共有する

はじめに?

SwiftUIで特定のViewをSNSにシェアするtipsを共有します?

使いたかったLPLinkMetadata

iOS13から使えるようになったLPLinkMetadataを利用します。
これを
IMG_0373.JPG
↓のようにすることができます。
IMG_0372.JPG

UIActivityViewControllerをRepresentaぶる

UIActivityViewControllerをSwiftUIで扱えるようにします。

struct ShareSheet: UIViewControllerRepresentable {
    let photo: UIImage

    func makeUIViewController(context: Context) -> UIActivityViewController {
        let text = "?"
        let itemSource = ShareActivityItemSource(shareText: text, shareImage: photo)

        let activityItems: [Any] = [photo, text, itemSource]

        let controller = UIActivityViewController(
            activityItems: activityItems,
            applicationActivities: nil)

        return controller
    }

    func updateUIViewController(_ vc: UIActivityViewController, context: Context) {
    }
}

iOS13からのLPLinkMetadataを使う

import LinkPresentation

class ShareActivityItemSource: NSObject, UIActivityItemSource {

    var shareText: String
    var shareImage: UIImage
    var linkMetaData = LPLinkMetadata()

    init(shareText: String, shareImage: UIImage) {
        self.shareText = shareText
        self.shareImage = shareImage
        linkMetaData.title = shareText
        super.init()
    }

    func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
        return UIImage(named: "AppIcon ") as Any
    }

    func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
        return nil
    }

    func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
        return linkMetaData
    }
}

ViewをUIViewに変換する

SwiftUI でViewをUIImageに変換する方法
前回書いた記事を参考にSwiftUIのViewをUIImageに変更する方法をご参照ください。

作成したShareSheetをモーダルで呼び出す

struct TestPage: View {
    @State private var rect: CGRect = .zero
    @State private var uiImage: UIImage? = nil
  // modalを表示するためのフラグを持つ
    @State private var showShareSheet = false

    var body: some View {
        VStack {
            HStack {
                Image(systemName: "sun.haze")
                    .font(.title)
                    .foregroundColor(.white)
                Text("Hello, World!")
                    .font(.title)
                    .foregroundColor(.white)
            }
            .padding()
            .background(Color.blue)
            .cornerRadius(8)
            .background(RectangleGetter(rect: $rect))
            .onAppear() {

            }

            Button(action: {
                self.uiImage = UIApplication.shared.windows[0].rootViewController?.view!.getImage(rect: self.rect)
                self.showShareSheet.toggle()
            }) {
                Image(systemName: "square.and.arrow.up")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: 50, height: 50)
                    .padding()
                    .background(Color.pink)
                    .foregroundColor(Color.white)
                    .mask(Circle())
            }.sheet(isPresented: self.$showShareSheet) {
              // 共有したいUIImageを渡す
                ShareSheet(photo: self.uiImage!)
            }.padding()
        }
    }
}

結果

IMG_0374.JPG

ちゃんとツイートできました!

全体のコード

https://gist.github.com/tsuzukihashi/d08fce005a8d892741f4cf965533bd56

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

SwiftUIでのMFMailComposeViewControllerの使用

前置き

やりたいこと

アプリ内からメールを送れるようにしたい。
sheetを用いてメーラーを表示し、ユーザーはそこで編集・送信・キャンセル等を行う。

環境

  • Xcode: 11.2.1
  • Swift: 5.1.2
  • 実機: 13.2.3

実装

下記のMailViewというViewを用意して、必要な箇所(今回の場合はContentView)でそれを呼び出すようにします。

MailView.swift
import SwiftUI
import MessageUI

struct MailView: UIViewControllerRepresentable {
    @Binding var isShowing: Bool

    func makeUIViewController(context: UIViewControllerRepresentableContext<MailView>) -> UIViewController {
        let controller = MFMailComposeViewController()
        controller.mailComposeDelegate = context.coordinator
        controller.setSubject("これが件名")
        controller.setToRecipients(["hogehoge@hogehoge.com"])
        controller.setMessageBody("これが本文", isHTML: false)
        return controller
    }

    func makeCoordinator() -> MailView.Coordinator {
        return Coordinator(parent: self)
    }

    class Coordinator: NSObject, MFMailComposeViewControllerDelegate, UINavigationControllerDelegate {
        let parent: MailView
        init(parent: MailView) {
            self.parent = parent
        }

        func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
            // 終了時の処理あれこれ

            self.parent.isShowing = false
        }
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext<MailView>) {
    }
}

呼び出し側

ContentView.swift
import SwiftUI
import MessageUI

struct ContentView: View {
    @State private var isShowingMailView = false

    var body: some View {
        Button(action: {
            self.isShowingMailView.toggle()
        }) {
            Text("Open MailView")
        }
        .disabled(!MFMailComposeViewController.canSendMail())
        .sheet(isPresented: $isShowingMailView, content: {
            MailView(isShowing: self.$isShowingMailView)
        })
    }
}

動作gif

ボタンを押してMailViewを開き、その後はキャンセルボタンを押して下の画面に戻っています。
※ 差出人メールアドレスは一時的に変更した仮のものを使用しています。
mailViewTest.gif

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

#17 Xcodeでアプリの向きを縦に固定する1例

はじめに

個人のメモ程度の出来なのであまり参考にしないで下さい.

環境

Xcode:11.2.1
Swift:5.1.2
2019/11

part1

project名を選択し,GeneralDeployment InfoDevice OrientationPortraitのみを選択した状態にする.

スクリーンショット 2019-12-01 午後8.33.04.png

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

Viewの制約が(結果として同じもの)を設定するときの状況別のパターン

動機

Youtubeチャンネルの素材用にTinderの模擬アプリを作成する際、
Viewの制約を設定するとき、結果として同じものがいくつかあり
どのパターンで制約を設定するか迷うことが個人的によくあります
->後になって設計や機能が詰まってきた時にあっちのパターンで制約かけておけばよかった。。。ということもしばしば。。

今後それをなるべく無くしたいので定期的にメモがわりに残します

状況と対策

superViewに対してViewにmarginをかける&中心は揃える

この場合パターンとして

  • 中心座標をsuperViewに揃える & Viewのwidth, heightをsuperViewwidht, height-marginにする
  • superViewに対してViewの縁(leading~bottom)をmargin分縮めたものにする

girlsViewArray[$0].leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 10.0).isActive = true
girlsViewArray[$0].trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -10.0).isActive = true
girlsViewArray[$0].topAnchor.constraint(equalTo: self.view.topAnchor, constant: 74.0).isActive = true
            //84.0 = 44.0(navHeight) + 20.0(statusBarHeight) + 10(margin)
girlsViewArray[$0].bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: -20.0).isActive = true

が挙げられると思います

個人的には
- 中心座標superViewに揃える & superViewwidht, height-marginにする
->Viewの座標が動く時(ex.ドラッグや大きさの変化)
- superViewに対してmargin分縮めたものにする
->Viewの座標が動かないと決まっている時

またわかったら追記します

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

AutoLayoutをコードで指定する際の注意点(NSLayoutAnchor)[Swift]

Youtubeチャンネルの素材用にTinderの模擬アプリを作成する際、
シンプルだけど長い時間はまってしまったのでメモ。

状況

Viewの制約といっても色々あるが、今回ハマったのはViewの縁を設定する制約。
(図のようにViewControllerの縁に対して上下左右10pxずつのmarginを設定して配置したい、という感じ。)
girlsView-ex.jpeg
Storyboardでの配置だとcustomViewだろうが標準Viewだろうが縁の制約はAdd New Constraints
こんな感じで10をひたすら入れていけばできてしまうが
スクリーンショット 2019-12-01 19.48.17.png
NSLayoutAnchorを使ってコードで設定したとき同じ容量でこんな感じでやったら

girlsViewArray[$0].leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 10.0).isActive = true
girlsViewArray[$0].trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: 10.0).isActive = true
girlsViewArray[$0].topAnchor.constraint(equalTo: self.view.topAnchor, constant: 10.0).isActive = true
girlsViewArray[$0].bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: 10.0).isActive = true

ViewViewControllerからはみ出してしまった

対策

NSLayoutAnchorconstantCGFloatの座標系(x: 右に行くほど増える, y: 下)かつequalToで設定した縁からの距離である
->Add New Constraintsはプラスの数値を入れれば選択したViewのsuperViewに対してスペースを作る(縮める)方向に勝手に制約を設定してくれる
以上を理解した上で修正した制約がこう(上下左右ViewControllerのViewに対して10pxずつマージンを持たせたい場合)
->trailingAnchor左に詰めたいのでconstantを-10.0にする必要がある
->bottomAnchor上に詰めたいのでconstantを-10.0にする必要がある

girlsViewArray[$0].leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 10.0).isActive = true
girlsViewArray[$0].trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -10.0).isActive = true
girlsViewArray[$0].topAnchor.constraint(equalTo: self.view.topAnchor, constant: 74.0).isActive = true
//84.0 = 44.0(navHeight) + 20.0(statusBarHeight) + 10(margin)
girlsViewArray[$0].bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: -10.0).isActive = true
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

写真・ビデオの撮影日時や再生時間を取得する

はじめに

カメラやフォトライブラリを使ったアプリを作ったときに、撮影日時と再生時間を取得する必要があって色々調べてみたので、書き残しておこうと思います。

UIImagePickerControllerを使用しているのですが、カメラで撮影した時と、フォトライブラリに保存されたものを選択した時とで取得方法が違ったので、少し苦戦しました。

とりあえず、これで目的は果たせているのですが、もっといい方法があるような気がしています。

環境:

Xcode 11.2.1
Swift 5.1.2

カメラで撮影したとき

撮影後に現在時刻を取得しても大差ない気はするんですけど、なんか意地で。

写真の場合

撮影日時という訳ではなさそうですが、もっとも近い値かと。
簡単と思いきや、Exifの日付形式が意外な落とし穴。

ViewController.swift
import UIKit

class ViewController: UIViewController, UIImagePickerControllerDelegate, UINavigationControllerDelegate {

    // 〜カメラ・フォトライブラリの呼び出しや終了については省略〜

    // 撮影または写真の選択が完了した時に呼ばれる
    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]){

        var shootingDate: String = ""    // 撮影日時

        // メタデータからExifを取り出し、撮影日時を取得
        guard let metadata = info[.mediaMetadata] as? NSDictionary else { return }
        guard let exif = metadata.object(forKey: kCGImagePropertyExifDictionary) as? NSDictionary else { return }
        let dateTimeOriginal = exif.object(forKey: kCGImagePropertyExifDateTimeOriginal)
        shootingDate = String(describing: dateTimeOriginal)

        // 取得したデータはなぜか「0000:00:00 00:00:00」という形式なので、最初の2つだけ「:」を「-」に置き換え
        var count = 0
        while count < 2 {
            count += 1
            if let range = shootingDate.range(of: ":"){
                shootingDate.replaceSubrange(range, with:"-")
            }
        }
        print("shootingDate: \(shootingDate)")
    }
}

ビデオの場合

ビデオはExifデータを持っていないので、AVURLAssetから撮影日時を取得しています。

ViewController.swift
import UIKit
import Photos    // AVURLAsset()やPHAssetを使うのに必要

class ViewController: UIViewController, UIImagePickerControllerDelegate, UINavigationControllerDelegate {

    // 〜カメラ・フォトライブラリの呼び出しはここでは省略〜

    // 撮影または写真の選択が完了した時に呼ばれる
    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]){

        var shootingDate: String = ""    // 撮影日時
        var duration: String = ""        // 再生時間

        // 日時の表示形式はお好みで
        let formatter = DateFormatter()
        formatter.timeZone = TimeZone.current
        formatter.locale = Locale.current
        formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"

        // AVURLAssetを使ってファイルから撮影日時を取得
        guard let fileUrl = info[.mediaURL] as? URL else { return }
        let video = AVURLAsset(url: fileUrl)
        let creationDate = video.creationDate?.value as! Date
        shootingDate = formatter.string(from: creationDate)

        // ついでにビデオの再生時間も取得
        let durationTime = TimeInterval(round(Float(video.duration.value) / Float(video.duration.timescale)))
        duration = generateDuration(timeInterval: durationTime)

        print("shootingDate: \(shootingDate)")
        print("duration: \(duration)")
    }

    // ビデオの時間を「00:00」表示に変換して返す
    func generateDuration(timeInterval: TimeInterval) -> String {

        let min = Int(timeInterval / 60)
        let sec = Int(round(timeInterval.truncatingRemainder(dividingBy: 60)))
        let duration = String(format: "%02d:%02d", min, sec)
        return duration
    }
}

フォトライブラリから選択したとき

info[.referenceURL]がDeprecateになって、代わりにinfo[.phAsset]を使うことになったのかな?
でも情報が見つけられなくて、意外と苦労した箇所(情弱の可能性)。

写真もビデオも同じ方法で取得できます。
写真の場合はdurationのくだりをカットしてください。

ViewController.swift
import UIKit
import Photos    // AVURLAsset()やPHAssetを使うのに必要

class ViewController: UIViewController, UIImagePickerControllerDelegate, UINavigationControllerDelegate {

    // 〜カメラ・フォトライブラリの呼び出しはここでは省略〜

    // 撮影または写真の選択が完了した時に呼ばれる
    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]){

        var shootingDate: String = ""    // 撮影日時
        var duration: String = ""        // 再生時間

        // 日時の表示形式はお好みで
        let formatter = DateFormatter()
        formatter.timeZone = TimeZone.current
        formatter.locale = Locale.current
        formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"

        guard let asset = info[.phAsset] as? PHAsset else { return }
        shootingDate = formatter.string(from: asset.creationDate!)
        duration = generateDuration(timeInterval: asset.duration)

        print("shootingDate: \(shootingDate)")
        print("duration: \(duration)")
    }

    // ビデオの時間を「00:00」表示に変換して返す
    func generateDuration(timeInterval: TimeInterval) -> String {

        let min = Int(timeInterval / 60)
        let sec = Int(round(timeInterval.truncatingRemainder(dividingBy: 60)))
        let duration = String(format: "%02d:%02d", min, sec)
        return duration
    }
}

参考にしたURL

Swiftで画像のExifデータから作成日時の情報を得る

【Swift】動画撮影&保存&取得【UIImagePickerController】

AVURLAsset - AVFoundation | Apple Developer Documentation

AVAsset - AVFoundation | Apple Developer Documentation

最後に

至らない点や無駄な記述など、お気付きの点はご指摘いただけるとありがたいです!

 

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

[Swift] AnyPokemonリターンズ ~型消去型の弁明~

Swift Advent Calendar 2019 2日目の記事です。

書いたこと

  • 型消去型とは
  • 型消去型の大変心苦しい立場

型消去型とは

AnyPokemon について

AnyPokemonは、平常心で型を消しさるで紹介された型消去型です。

これを変更して、型消去型とは何なのか、どうして必要なのかを考えてみます。

AnyPokemon おさらい

ポケモンは、雷属性ポケモン、炎属性ポケモンのように属性によって分類することが出来ます。

この属性の違いを型として表す場合、オブジェクト指向で考えれば、ポケモンクラスからサブクラスとして派生させるアプローチが一般的です。

ですが、Swiftのstructは継承が許されていません。
そこでProtocolを用います。(※1)

Pokemonプロトコル
// Pokemonプロトコルは属性を表すtypeを持つ
protocol Pokemon {
    // 属性を表す型
    associatedtype PokemonType
    var type:PokemonType { get }
}

Pokemonプロトコルを用いると、雷属性のピカチューは以下のように表すことが出来ます。

雷属性型とピカチュー
// 雷属性を表す
struct Electric {}
// ピカチュー
struct Pikachu: Pokemon {
    var type = Electric()
}

しかしPokemonプロトコルは型宣言として用いることが出来ません。

// NG 型宣言としては使えない
var pokemon:Pokemon = Pikachu()

これはSwiftの言語仕様として、Self もしくは associatedtype を持つprotocolは型宣言として使用することが出来ないためです。

そこでAnyPokemonの登場です。

AnyPokemon
struct AnyPokemon<T>:Pokemon where T:Pokemon {
    var type: T.PokemonType {
        return self.rawPokemon.type
    }
    var rawPokemon: T
    init(_ pokemon: T) {
        self.rawPokemon = pokemon
    }
}

AnyPokemonを使えば、雷属性のポケモンを受け取る型を宣言することが出来ます。

// OK
let electricPokemon:AnyPokemon<Electric> = AnyPokemon(Pikachu())

// Arrayの型変数としてもOK
let electrics:[AnyPokemon<Electric>} = [AnyPokemon(Pikachu()), AnyPokemon(Raichu())]

AnyPokemonの注目ポイントは2つです。

  1. 型宣言することが出来る
  2. Pokemonプロトコルである

つまり型消去型とは何らかのプロトコルの具体型であるといえます。

型消去型 in 標準ライブラリー

標準ライブラリーに用意されている型消去型は、Any~ と命名されています。
その中で以下の2つの型消去型をご紹介します。

  • AnyHashable
  • AnySequence

AnyHashable

AnyHashableは、その名の通り、Hashableプロトコルの具体型です。

SwiftのDictionaryは、同時に複数の型をキーとして取るこことが出来ません。

// コンパイルエラー
let desc = ["?" : "emoji", 42 : "an Int"];

キーに複数の型のインスタンスを受け付けるようにするには、型宣言としてHashableプロトコルを宣言する必要がありますが、Selfassociatedtypeを持ちますので型宣言に用いることは出来ません。

Hashableを型宣言に用いることが出来ない
// NG
let desc:[Hashable: String] = ["?" : "emoji", 42 : "an Int"];

そこでAnyHashableの出番です。

// OK
let desc = ["?" : "emoji", 42 : "an Int"] as [AnyHashable : Any]

つまりAnyHashableを使えば、個々の型を消去し、AnyHashable型としてまとめることが出来ます。

AnySequence

AnySequenceSequenceプロトコルの具体型です。
こちらもSelf,assciatedtypeを持つため型宣言として利用することが出来ないため、AnySequenceを使うことが出来ます。

// OK
var numbers: AnySequence<Int> = [1, 2, 3, 4, 5]

ですが、上記AnySequenceを用いる理由は全くありません。
ただ[Int]と宣言すればいいだけです。。。

それではAnySequenceはどういった場面において、有効的な使い方が出来るのでしょうか?
それを考えてみます。

型消去型の大変心苦しい立場

型消去型には妬ましい強敵が存在します。
ジェネリクスです。

そのため影に追いやられている大変辛い存在になってしまっています。
なんとか日陰から日の当たる場所に出してあげたいと思います。

そこで一旦ジェネリクスの存在を無視し、AnySequenceを使って型消去型の意義を考えます。

型消去型の意義を考える

文字列をトークンに変換する関数を考えます。

func convertToken(from str: [Character]) -> Token { ...

この関数を文字列から直接呼び出す場合、Array型を生成するコストO(N)が発生します。

// 呼び出すたびにコストO(N)が発生する
let token = convertToken(Array("◎だこのたこ焼きを食うと胸がやける?"))

それならば、文字列として宣言すればいいじゃない?

func convertToken(from str: String) -> Token { ...

では文字列をソートしてからトークンに変換するとなったとき、どうしましょうか?

// sortedメソッドの返り値は[Character]
let token = convertToken(from: String(str.sorted()))

一旦、配列に変換された文字列を再度文字列に変換する必要が発生します。

そこでAnySequenceの出番です。

func convertToken(from chars: AnySequence<Character>) -> Token { ...
// 文字列から直接
let token = convertToken(from: AnySequence(str))

// ソートしてから
let token2 = convertToken(from: AnySequence(str.sorted()))

このように受け取れる型の定義域を広げることで、接続をしやすくすることが出来ます。
これは型が非常に強いプログラミング言語においてはとても重要なことです。

ジェネリクスとの比較

残念ながら型消去型を積極的に使われることは殆どありません。
なぜならばジェネリクスを使えば、コンパイル時点で、呼び出し箇所で型を決定することができ、最適化の恩恵も受けることが出来るからです。

強いて言うならば、ジェネリクスは読み解くのが難しいと言えるかもしれません。

以下電撃属性のポケモンを受け取る関数を見比べてみれば、一目瞭然です。

型消去型とジェネリクスの見比べ
// ジェネリクス
func pokemonWithElectric<U>(_ pokemon: U) where U:Pokemon, U.PokemonType == Electric {
    ....
}

// 型消去型
func pokemonWithElectric(_ pokemon: AnyPokemon<Electric>) {
    ....
}

またジェネリクスは自分で定義する必要もありますが、標準ライブラリーには必要となる型消去型が沢山用意されています。

まとめ

  • 型消去型とは あるプロトコルの具体型 であると言える
  • 型消去型を使う利点
    • 接続がしやすくなる
    • ジェネリクスよりも可読性が高い
    • ジェネリクスと違って沢山の型消去型が用意されている

Appendix

型消去 (Type erasure)型消去型 (type-erased type) について

型消去という名詞は、Javaでも使われていますが、ここでの型消去とは意味が異なります。
(Javaの型消去は、JVM上では型が消去されることを意味します。)
ここでの型消去は、type-erased という形容詞になります。後ろにtypeという名詞が続いています。

まぁ型消去は名詞なのでどちらに使っても間違いではないと思いますが、より正確に言うならば型消去型(type-erased type)です。

structとenumについて

※1から、Swiftのstructは、直積タイプの代数的データ型であることを意味します。またenum直和タイプの代数的データ型です。

参照

型消去の話で出てきたポケモンの例題を理解する #tryswiftconf

型消去 in Swift

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

【Swift】associatedtypeの使いどころ

使いどころがよくわかってなかったけど気になっていたSwiftのassociatedtype
個人開発アプリでいい感じに使えたのでここに書いておきます。

associatedtypeとは

  • protocolに定義する連想型です
  • protocolの準拠時に、具体的な型を指定します(または型推論で指定されます)
  • ジェネリクスにおけるT的なやつです

使いどころ

上記の通りなのですが、protocol定義時点では決められず、準拠側で指定したい型があるときが使いどころです。
具体的には、APIを叩いて、レスポンスに含まれるJSONから特定の型を作りたい!ってときに使えました。

具体例

以下、AlamofireとQiita APIをサンプルに使った例です。

やりたいこと

これを単純に書くとこんな感じになります。

protocol、associatedtypeを使わないパターン.swift
func hoge() {
    Alamofire
        .request("https://qiita.com/api/v2/users/akeome")
        .response(completionHandler: { response in
            guard
                let data = response.data,
                let user = try? JSONDecoder().decode(User.self, from: data) else {
                    return
            }

        print(user.id)  // akeome
        print(user.userDescription)  // ぁけぉめです。以下略
    })
}

なお、ここでのUser型はJSONを元に自動生成1したCodableなstructです。

もっとよくできないかしら

上記のような処理を繰り返し書くことがあれば、こんな不満と願望が湧いてきます

  • この長い処理を何度も書きたくない → リクエストメソッド自体は共通化したい
  • 処理を呼ぶたびにURLとレスポンスJSONの型をいちいち書きたくない → APIの呼び出し先を指定するだけでJSONをデコードする型も決まってほしい

こんな願いを叶えてくれる機能がSwiftにはちゃんと備わっているのです。
それがprotocol、そしてassociatedtypeです。

protocolにしてみよう

まずはAPIを呼び出せるprotocolを考えましょう。
リクエストメソッド自体は共通化しつつAPIの呼び出し先を変えるには、protocolが使えそうです。

APIConfigure.swift
protocol APIConfigure {
    // APIの呼び出し先
    static var path: String { get }
    // リクエストメソッド(※クロージャは後述)
    static func request()
}

呼び出し先は準拠する側で決めるとして、リクエスト処理は共通なのでprotoocol extensionで実装します。

APIConfigure.swift
extension APIConfigure {
    static func request() {
        Alamofire
            .request(Self.path)
            .response(completionHandler: { response in
                guard
                    let data = response.data,
                    //                                  ????
                    let xxx = try? JSONDecoder().decode(User.self, from: data) else {
                    return
                }
            })
    }
}

さてここで困ったことがあります。
JSONDecoder().decode()の第一引数に渡す型を指定しなければならないのです!

共通化したい!でも具体的な型はまだ決められん!準拠側で指定したいんや!
そんなときこそassociatedtypeの出番です。

そう、associatedtypeならね

associatedtypeの定義

protocol本体の実装に戻って、「今はまだ決められないけど準拠時に決めてね」という型を定義します。
今回の例の場合、準拠時にUser型に指定する型です。

APIConfigure.swift
protocol APIConfigure {
    associatedtype ResponseEntity  // ?追加

    // APIの呼び出し先
    static var path: String { get }
    // リクエストメソッド(※クロージャは後述)
    static func request()
}

protocol extension側を修正していきます。

APIConfigure.swift
extension APIConfigure {
    static func request() {
        Alamofire
            .request(Self.path)
            .response(completionHandler: { response in
                guard
                    let data = response.data,
                    //                                            ?associatedtype
                    let responseEntity = try? JSONDecoder().decode(ResponseEntity.self, from: data) else {
                    return
                }
            })
    }
}

型制約の追加

このままでは以下のエラーになります。

Instance method 'decode(_:from:)' requires that 'Self.ResponseEntity' conform to 'Decodable'`

JSONDecoder().decode()の第一引数に渡す型はDecodableに準拠している必要があるのです。
このままでは、ResponseEntityがJSONから変換可能な型なのかどうかがわからないのです。

そこで型制約の追加です。

APIConfigure.swift
protocol APIConfigure {
    //                            ?型制約の追加
    associatedtype ResponseEntity: Codable

    // APIの呼び出し先
    static var path: String { get }
    // リクエストメソッド(※クロージャは後述)
    static func request()
}

これでResponseEntityはJSONから変換可能な型(=Codable)であると制約をかけられました。

そしてクロージャへ

リクエスト処理にクロージャを追加して、呼び出し元でJSONをデコードした型として扱えるようにします。

APIConfigure.swift
protocol APIConfigure {
    associatedtype ResponseEntity: Codable

    // APIの呼び出し先
    static var path: String { get }
    // リクエストメソッド               ?クロージャを追加
    static func request(completion: ((ResponseEntity) -> ())?)
}
APIConfigure.swift
extension APIConfigure {
    static func request(completion: ((ResponseEntity) -> ())?) {
        Alamofire
            .request(Self.path)
            .response(completionHandler: { response in
                guard
                    let data = response.data,
                    let entity = try? JSONDecoder().decode(ResponseEntity.self, from: data) else {
                    return
                }

                completion?(entity)
            })
    }
}

ここまでで、悲願の「リクエストメソッドの共通化」「APIの呼び出し先を指定するだけでJSONをデコードする型の確定」を満たせそうなprotocolができあがりました。

準拠側

Qiita APIのユーザー情報取得を扱うstructを作り、protocolに準拠させます。

UserGet.swift
struct UserGet: APIConfigure {
    typealias ResponseEntity = User  // ?ここで具体的な型を指定
    static let path = "https://qiita.com/api/v2/users/akeome"
}

protocolで定義したassociatedtypeを準拠側で明示的に指定するにはtypealiasを使います。
これでUserGetを使ったリクエスト処理のクロージャで受け取る型はUser型に指定できました。

APIを呼び出す共通的なstructを作ってまとめていくことが考えられます。

APIClient.swift
struct APIClient {
    // ユーザー情報取得
    struct UserGet: APIConfigure {
        typealias ResponseEntity = User
        static let path = "https://qiita.com/api/v2/users/akeome"
    }

    // 記事一覧取得
    struct ItemsGet: APIConfigure {
        typealias ResponseEntity = [Item]
        static let path = "https://qiita.com/api/v2/items"
    }

    // その他いろいろ
}

associatedtype使ってみた結果

protocol、associatedtypeを使ったパターン.swift
func hoge() {
    APIClient.UserGet.request(completion: { user in
        print(user.id)  // akeome
        print(user.userDescription)  // ぁけぉめです。以下略
    })
}

こんな感じで、リクエスト処理のクロージャで受け取る型を自動的にUserにできました。
とてもすっきりしたのではないでしょうか。

今後ユーザー情報取得だけでなく記事一覧も取得したくなっても
APIClient.GetItems.request(〜と書くだけです。

比較のため、冒頭に記載したassociatedtypeを使わないパターンも再掲しておきます。

protocol、associatedtypeを使わないパターン.swift
func hoge() {
    Alamofire
        .request("https://qiita.com/api/v2/users/akeome")
        .response(completionHandler: { response in
            guard
                let data = response.data,
                let user = try? JSONDecoder().decode(User.self, from: data) else {
                    return
            }

        print(user.id)  // akeome
        print(user.userDescription)  // ぁけぉめです。以下略
    })
}

まとめ

今回は割愛しましたが、

  • protocolにHTTPMethodを持たせてGETやPOSTに対応する
  • protocolにパラメーターを持たせる
  • protocol extensionでベースURLを定義する
  • リクエスト処理のエラーに対応する

などすれば、より実用的なコードになるかと思います。

associatedtypeについての記事がなかなか見つからなかったので参考になる方がいらっしゃれば幸いです。

おまけ

associatedtypeを使ってこんなアプリを作ってます。
CardPort - App Store

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

【Xcode】細分化する iOS Architecture に向き合う上で気をつけなければらないただひとつのエラーについて

はじめに

こんにちは。Reactive な世界に生命の息吹を感じるたかねです。

今年もやってきました,iOS Advent Calendar 2019 1日目です!
みなさまよろしくお願いいたします!

本記事は unable to spawn process (Argument list too long) という Xcode からのすてきな (!) メッセージについてです。
普段の開発では見慣れないエラーかと存じます。しかし,本問題を知り,本問題を見据えて開発することは,きっと数年後の iOS 開発者であるあなたの役に立つと思います。少し長いですが,ぜひご覧いただけましたら幸いです。

目次

最近のソフトウェアアーキテクチャに関する流行と振り返り

本題に入る前に少しおさらい、もとい振り返りをさせてください。2018年は iOSアプリ設計パターン入門 を始め,Clean Architecture: A Craftsman's Guide to Software Structure and Design の訳書である Clean Architecture 達人に学ぶソフトウェアの構造と設計 が出版され,日本の iOS 開発者が様々なソフトウェアアーキテクチャに対し知見を得る1年となりました。実際に MVVM を始め、多くの企業は細分化されたアーキテクチャを採用している様子が確認できるようになってきています。我々 iOS エンジニアは設計というスキルを得ることができ,次なる問題に取りかかることができるようになりました。

プロジェクトが適切に肥大化し続ける中待ち受ける,たったひとつのエラー

閑話休題。ここでの「プロジェクト」とは Xcode の Project と同義であり,ソースコードとその周辺環境を指します。
過去に FATViewController などと揶揄されたこともあった UIViewController へのドメインロジック混入による肥大化は,View と Model を分離 (プレゼンテーションとドメインの分離; Presentation Domain Separation) することを目的とした各種設計により解決されました。

素晴しく綺麗な環境で開発される iOS プロジェクトは,適切に分割されたレイヤーによりテストがし易く堅牢で,かつ明快である。その環境下では無限にスケールしてもその効用を維持し続けることができる,その最たる例が Clean Architecture である。

そう,私もそう信じてやみませんでした。件のエラーが発生するまでは。

以下をご覧ください。これは,ある条件下において Xcode でビルドした際に発生するエラーです。

unable to spawn process (Argument list too long) エラーです。

これが Xcode である証拠に,画面全体のスクリーンショット も添付しておきます。

また,xcodebuild コマンドによる CLI 経由でも発生します。

これは一体何でしょうか? この記事をご覧になっている皆様にも発生する問題なのでしょうか?

unable to spawn process (Argument list too long) とは? その発生条件

unable to spawn process (Argument list too long) エラーが発生する原因はただひとつ。エラー文の通り「引数リストが多過ぎる」というエラーです。

Compile Swift source files ステップにて, コンパイラに渡されるソースファイルの Full path とオプションの文字数の合計がおよそ 260,000 文字程度※1 を越えるとコンパイルが必ず失敗する というものです。

............遭遇しなければ気がつかないエラーです?????

いくつかの対処方法と,Xcode 11 における解決方法

ファイル数が多い,絶対パスが長くなるような深いディレクトリに存在する,極端に長いファイル名を使用しているなどの原因により本問題は発生します。

その解決方法は swiftc (Swift コンパイラ) に対し渡すコマンドの総量をどうにかして減らすことのみでした。今秋リリースされた Xcode 11 ではようやく解決可能なフラグが追加されました。
いくつかの対処方法と,Xcode 11 における解決方法を順に解説いたします。

1. ライブラリをビルド済みバイナリ形式でビルドする

こちらは CocoaPods を利用している場合に限ります。Carthage はビルド済みバイナリを利用しますが,CocoaPods の場合ビルドが含まれるため,その分パスが長くなります。どうしても CocoaPods を利用したい方は,cocoapods-binary というプラグインを導入することで,インストール時にバイナリとして事前にコンパイルさせておくことが可能です。

余談ですが,cocoapods-binary はオプションによりソースコードを残しつつバイナリを生成することもできます。本オプションにより,Carthage の弱点であった「開発時に Xcode 内からライブラリ側のソースコードを辿れない」という問題に対応しつつ,ビルド時にはコンパイル済みのバイナリを利用してコンパイルを早くするといったことが可能となります。便利です。

2. スクリプトによってコンパイル対象のソースファイル名を変更する

愚直な解決方法として挙げられるのはこちらです。「ファイル名の長さが原因」なので,実際にコンパイラに渡すまでに短いファイル名に変更してからコンパイルするようにすれば良い,ということになります。
具体的な方法はあまりにも泥臭いため省略します。

3. プロジェクトをルートディレクトリに移動させる

コンパイラに渡されるソースファイルのパスは Full Path であるという仕様を利用する方法です。リポジトリを Clone するディレクトリをルート直下に配置するだけです。全てのソースファイルに影響があるためファイル数が多いプロジェクトほど効果があります。ちなみに Apple の Developer forum にも同様の提案がされています。
一方で,これは一時的解決に過ぎず,この状態で開発を続けてもいずれエラーとなることは目に見えています。
(Bitrise がルートディレクトリに Clone する仕様で助かりましたね!)

4. Embeded Framework を利用してコンパイルする

Embeded Framework を用い,分割コンパイルすることで一度にコンパイルするソースファイルの数を減らす方法です。これが一番まともであり,根本解決とは異なりますが正攻法と言えるでしょう。

5. USE_SWIFT_RESPONSE_FILEYES と指定する (Xcode 11 以降)

Xcode 11 以上の環境で build settings USE_SWIFT_RESPONSE_FILEYES を代入する ことで解決できるとの情報が更新されていました。Xcode 10 まででは根本解決に至らなかったこのエラーは,Xcode 11 によってようやく解決されたようです。

まとめ

  • Xcode には コンパイラへ渡すソースファイルの内容によって unable to spawn process (Argument list too long) が発生する致命的な問題を潜在的に抱えていた
  • Xcode 11 未満は Embeded Framework による Target 分割を始めとした回避方法が存在する
  • Xcode 11 以降であれば USE_SWIFT_RESPONSE_FILEYES を代入することで解決できる

秋に発表された Xcode 11 は図らずとも我々に良い結果をもたらしました。
一方で堅牢な設計を維持するためには Embeded Framework を利用することがコンパイル時間の短縮のためにも良いかと思います。投稿が少し遅くなりましたが,ここらで筆を置かせていただきます。

(おまけ) なぜ日本語記事が存在しなかったのか

本章は考察です。Xcode には以前よりこのような問題を抱えていました。少なくとも 2017 年には
私が iOS 開発を始めたのも 2017 年ですので、それ以前の流れは分かりません。設計に関する関心は恐らくこれと同じ時期、もしくはそれ以後に盛況を呈したものと思われます。

2017 年以後,設計に関心を持つ開発者が本問題に遭遇することはなかったのか?

この疑問については,周囲から話を聞く限りでは「細分化される Embeded Framework を利用しており,発生する前に自然と回避していた」と考えられます。

Swift における名前空間について

他言語ではディレクトリ名が名前空間として予約され,異なるディレクトリ間でも問題なく扱える機能がありますよね。
Swift における名前空間は暗黙的であり,利用するためには Framework による Target の分離が必要です。Framework によって分割するということはコンパイルソースを分割することにもなるため,名前空間による解決手法は結果的に上で記載した手法に含まれます。
(この仕様自体も,ソースファイルの命名が長くなる要因だったり......?)

エラー原因の背景1 – XcodeGen の導入とリファクタリングの順序 –

今回エラーとなった原因の背景には「MVC から Clean Architecture へのリファクタリング → XcodeGen によるコンフリクトの解消 → Embeded Framework による分割」 のステップを検討しており,同時に Framework 化を進められなかった事情がありました (こんなエラーが発生することを予期できていれば最初から Target を分けて作成していましたよ!)。その結果,1 target に全ての層が入りこみ,MVC から分割されたファイルは必然的に増える結果を生みました。

エラー原因の背景2 – Presentation 層への Atomic design を用いた介入 –

さらに,今日までのソフトウェアアーキテクチャにおける欠点のひとつに「View (Presentation) の実装については関心を持たない」という点も加筆しておかねばなりません。昨年 Sketchと1対1を目指すAtomic designなStoryboardの作り方 - Qiita という記事を書きました。Presentation 層に対し無関心であった部分について,Atomic design を利用して再利用可能なレベルに分割する手法を導入しました。こちらも,再利用を意識し過剰な .xib ファイルとその対となる .swift が生成され,ファイル数の増加に繋がりました。

Presentation 層におけるかすかな希望

Atomic design 自体が悪かった訳ではありません。実際に再利用によってボタンやフォームを始めとする UI の作成速度は向上し,意図せぬ再実装がなくなりました。また,SwiftUI の登場は Storyboard および Xib ファイルからの開放,コード生成と GUI による生成の共存という結果を生み出しました。これだけでも旧実装よりファイル数,コード数ともに削減させる一手と成り得る希望を生みました (完全に Replace できるかについて,ここでは言及を避けるものとします)。

注釈

※1) 上限の境界については未検証のため正確ではありません。263,627 文字で失敗を確認しています。

文献

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

XcodeGenを使うとチーム開発が楽になる話

この記事は豊田高専コンピュータ部アドベントカレンダー1日目の記事です

XcodeGenとは

iOSアプリ開発で複数人での開発をすると色々とつらいことがあるのでそのつらいことをなるべく無くそうという感じのツールです。具体的にはプロジェクトへのファイルの追加など行うとプロジェクトファイルが書き換わるのですが、そのプロジェクトファイルは人間におおよそ理解のできない形式で書かれており、チーム開発をしているとそれが恐ろしいコンフリクトを生み、その解消がとてもつらいという問題があります。そのプロジェクトファイルをコード(yaml)から生成することでチーム開発をやりやすくしようという感じのツールになります。
https://github.com/yonaskolb/XcodeGen

触ってみる

なにはともあれ百聞は一見にしかずなので触ってみましょう。まずはXcodeGenをインストールします

$ brew install xcodegen

XcodeGenを入れられたら次はXcodeのプロジェクトを作成します。プロジェクト名は今回は「SampleGen」とします。プロジェクトの作成手順は今回は割愛します。

プロジェクトを作成できたらプロジェクトファイルの内容をproject.ymlに書き起こしていきます。

project.yml
# プロジェクト構成・設定
name: SampleGen
configs:
  Debug: debug
  Release: release
options:
  groupSortPosition: top
attributes:
  ORGANIZATIONNAME: ymgn
fileGroups:
 - SampleGen
 - SampleGenTests

# ターゲット
targets:
  SampleGen:
    type: application
    platform: iOS
    sources:
      - path: SampleGen
    settings:
      base:
        PRODUCT_BUNDLE_IDENTIFIER: ymgn.SampleGen
        ASSETCATALOG_COMPIER_APPICON_NAME: AppIcon
        INFOPLIST_FILE: SampleGen/Info.plist
      configs:
        Debug:
          GCC_OPTIMIZATION_LEVEL: O
        Release:
          GCC_OPTIMIZATION_LEVEL: s
  SampleGenTests:
    type: bundle.unit-test
    platform: iOS
    dependencies:
      - target: SampleGen
    settings:
      TEST_HOSTS: $(BUILT_PRODUCTS_DIR)/SampleGen.app/SampleGen
      INFORPLSIT_FILE: SampleGenTests/Info.plist
    sources:
      - SampleGenTests

# スキーム
schemes:
  SampleGen:
    build:
      targets:
        SampleGen: all
    run:
      config: Debug
    test:
      config: Debug
      gatherCoverageData: true
      targets:
        - SampleGenTests
    profile:
      config: Release
    analyze:
      config: Debug
    archive:
      config: Release

要素一つ一つを説明するのはつらいのでXcodeGenのリポジトリにあるドキュメントを参照してもらうとして、プロジェクトファイルの項目に対応した要素が存在します。プロジェクトファイルからyamlへの書き起こしは手で書きましたが、自動で書き起こしてくれる方法などあればぜひコメントで教えてください。

project.ymlはプロジェクトファイルと同じ階層に配置し、プロジェクトに追加する必要はありません。
project.ymlが出来上がったらプロジェクトファイルは削除しても構いませんが、写しミスがあった場合悲しいので残しておくとよいでしょう。
project.ymlがあるディレクトリで

$ xcodegen generate

を実行するとプロジェクトファイルを生成します。既存のプロジェクトファイルがある場合は書き換えられます。

最低限の実装になっているのでそのままビルドするとおそらくSigningでエラーが出ると思いますので、Teamは手動で追加してもらうとエラーは消えるはずです。

これで一通りの導入は終了です。

まとめ

上記では手順だけ示したのであまり恩恵がわからないかもしれませんが、targetの追加や、ライブラリ、Embeded Frameworkの追加などもproject.ymlで記述できるのでコマンド一発でどの環境でもコンフリクトのない状態のプロジェクトファイルが生成されます。実際に使用する際はプロジェクトファイルは.gitignoreなどで管理には含めないようにすることに注意してください。
もっと詳しく知りたい場合はXcodeGenのリポジトリや他記事を参照してもらえると多くの設定が利用できたり、設定ファイルを分割できたりすることがわかると思います。iOSアプリのチーム開発で困っている人はぜひ試してみてください。

おまけ

XcodeGenを使ったプロジェクトをBitriseなどのCI/CDツールに載せるとプロジェクトファイルが存在しないため、validationが通らなくて焦るかもしれません。しかし、最初のvalidationが通らなくてもPROJECT_PATHには単に生成される.xcodeproj(もしくは.xcworkspace)のファイル名を入力して、workfrowにXcodeGenをビルド前に実行するようにすればビルドできるようになります。

スクリーンショット 2019-12-01 16.23.10.png

Bitriseであればこのようにxcodegenのコマンドを使用するためのユーティリティが存在します。

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

【Swift】Delegateとは(別オブジェクトへの処理の委譲)

 私がiOSアプリを開発する中で、デリゲートパターンについてなーなーなまま進めて痛い目に遭ってきたので今回ここにまとめたいと思います。おかしいと思うところがあったらどしどしコメントください。

デリゲートパターン

 デリゲートパターンは、あるオブジェクトの処理を別のオブジェクトに代替させるパターンです。デリゲートもとのオブジェクトは、適切なタイミングでデリゲート先のオブジェクトに通知を送り、その通知を受けたデリゲート先のオブジェクトは、自身や別のオブジェクトの状態を変更したり、結果をデリゲート元のオブジェクトに返したりすることができます。
 デリゲートとして他のオブジェクトに渡す処理は、事前にプロトコルとして宣言しておく必要があります。

実装例

 今回の例では、デリゲート元であるGreetingクラスの処理(greeting()メソッド内のstrやdidMorning、didAfternoon)を、Greetクラスに委譲します。

 まず、protocolにデリゲート元に渡したい処理を記述します。デリゲート先のクラスはこのprotocolに準拠すべき(GreetクラスはGreetDelegateに準拠したクラスclass Greet: GreetDelegate)で、渡す処理を全て実装しておかなければなりません。そのため、protocol内に記述した処理がない場合はコンパイルエラーになります(処理が実装されてませんってエラー出してくれるからわかりやすい)。

delegateSapmple.prayground
import UIKit
//デリゲート先のクラスにて、
//デリゲート元に渡したい処理をprotocolとして宣言する
protocol GreetDelegate: class {
    var str: String { get }
    func didMorning(_ greet: Greeting)
    func didAfternoon(_ greet: Greeting)
}

//デリゲート先のクラス
class Greet: GreetDelegate {
    var str: String { return "Hey!" }

    func didMorning(_ greet: Greeting) {
        print("Good morning!")
    }

    func didAfternoon(_ greet: Greeting) {
        print("See you!")
    }
}

//デリゲート元のクラス
class Greeting {
    weak var delegate: GreetDelegate?

    func greeting() {
        print("\(delegate?.str ?? "Default")")
        print("In the morning...")
        delegate?.didMorning(self)
        print("In the afternoon...")
        delegate?.didAfternoon(self)
    }
}

//結果を確認しよう
let delegate = Greet()
let greeting = Greeting()

//Greetingクラスのインスタンスgreetingのデリゲート先として、
//Greetクラスを指定する
greeting.delegate = delegate
//greeting()を実行!
greeting.greeting()

結果↓

Hey!
In the morning...
Good morning!
In the afternoon...
See you!

 Greetingクラスは、greeting()メソッドの中で、delegateプロパティを通じてデリゲート先のクラスにstrを問い合わせています。また、朝の挨拶(didMorning)と昼の挨拶(didAfternoon)のタイミングをデリゲート元に伝えています。

関数の命名規則や例

 デリゲートパターンでは、デリゲート先(Greetクラス)にデリゲート元(Greetingクラス)から呼び出されるメソッド群を実装する必要があります。実装する必要のあるメソッドは、プロトコルとして宣言していました。
 メソッドやプロトコルの命名には規則が存在するようです。例えば、みなさんがよく使う(と思う)UITableViewクラスのデリゲートメソッドのなかに、UITableViewCellがタップされた時に実行されるメソッドがあります。それは以下のように宣言されています。

public protocol UITableViewDelegate: NSObjectProtocol, UIScrollViewDelegate {
    (...)
    optional public func tableView(_ tableView: UITableView,
                                   didSelectRowAt indexPath: IndexPath)

 デリゲートパターンではどのタイミングで呼ばれるかということを明示するため、didやwillをメソッドの先頭につけます。そして、デリゲート先が必要とする情報を引数として渡します。この例では、どのインデックスのセルがタップされたかという情報が必要なので、IndexPath型のindexPathが引数になっています。
 よく見ると第一引数にデリゲート元のオブジェクトを渡していますね。なぜかというと、複数のプロトコルに準拠する際、デリゲートメソッド同士の名前が衝突してしまうことがあるかもしれないからです。tableView(_:didSelectRowAt)メソッドの例では、セルが選ばれた(selectRow)後に(did)UITableViewクラスが呼ぶメソッドであるということが、メソッド名を見るだけでわかるようになっています。
 以上のことを踏まえ、Appleの提供するCocoa,Cocoa Touchフレームワーク内で利用されているデリゲートパターンでは次の命名規則にしたがっているとまとめることができます。

・didやwillを用いてメソッドの呼ばれるタイミングを示す
・メソッド名はデリゲート元のオブジェクト名から始め、続いて処理の内容を説明する
・第一引数にはデリゲート元のオブジェクトを渡す

 慣れてくるとこれらの規則を満たさなくても記述できると思います。しかし、名前の衝突を回避できたり、初めてコードを読む人にも理解させやすいといった利点があると思うので、命名規則は守る方がいいかなと思いました。

まとめ

 今回はSwiftのデリゲートについて細かく紹介しました。デリゲート元とデリゲート先が逆で捉えていたり、具体的な使い方がわからない方(私もまだまだわかっていません…)もいると思います。そう言った方達が少しでも理解してくれたら幸いです。(実践的なコードも更新していけたらいいなと思っています。よろしくお願いします。)

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

SwiftUI でViewをUIImageに変換する方法

もちべ?

特定のSwiftUIのViewをShareSheetを使ってシェアするために、特定のViewをUIImageに変換する必要がありました。
最初はUIHostingControllerなどを使ってVIewからUIImageを取得しようとしましたが、上手くいかず...

SwiftUIの問題は、大抵GeometryReaderを使えば解決できるという巷の噂に身を任せたら解決できました☕️
とにかくコード見せてって人はこちらから
https://gist.github.com/tsuzukihashi/41fbb0c594e26e317cfcec878e9948f4

よくわかる解説

まず解説のために適当なViewを用意しました。このViewをUIImageに変換していきます。

スクリーンショット 2019-12-01 15.06.46.png

struct TestPage: View {
    var body: some View {
        HStack {
            Image(systemName: "sun.haze")
                .font(.title)
                .foregroundColor(.white)
            Text("Hello, World!")
                .font(.title)
                .foregroundColor(.white)
        }
        .padding()
        .background(Color.blue)
        .cornerRadius(8)
    }
}

次にUIImageに変換したいViewのサイズと位置を取得したいです。
今回で言うとHStackのCGRectがわかれば良いでしょう。

ここで一つ目のポイントなのですが、ViewのCGRectを取得するためにbackgroundメソッドを利用します。
backgroundの引数はViewプロトコルに準拠していれば良いので、Viewを返しつつサイズを取得するようなRectangleGetterを作成します。

以下がbackgroundメソッドの定義です。Viewを返せば良いらしい?

func background<Background>(_ background: Background, alignment: Alignment = .center) -> some View where Background : View

https://developer.apple.com/documentation/swiftui/view/3278516-background

@Bindingで親ViewのCGRectを指定します。
ここでGeometryReaderの出番です!
GeometryReaderで親Viewのgeometryを取得し、createViewメソッドでViewのサイズと位置情報をBindingしたrectに与えます。
Viewを返さなくてはいけないので透明なRectangleViewを返します。

struct RectangleGetter: View {
    @Binding var rect: CGRect

    var body: some View {
        GeometryReader { geometry in
            self.createView(proxy: geometry)
        }
    }

    func createView(proxy: GeometryProxy) -> some View {
        DispatchQueue.main.async {
            self.rect = proxy.frame(in: .global)
        }
        return Rectangle().fill(Color.clear)
    }
}

先ほど作成した、TestPageに@Stateのrectを用意します。
HStackのbackgroundにRectangleGetterをかませます。

struct TestPage: View {
  //追加 >>>
    @State private var rect: CGRect = .zero
  // <<<
    var body: some View {
        HStack {
            Image(systemName: "sun.haze")
                .font(.title)
                .foregroundColor(.white)
            Text("Hello, World!")
                .font(.title)
                .foregroundColor(.white)
        }
        .padding()
        .background(Color.blue)
        .cornerRadius(8)
      // 追加 >>>
        .background(RectangleGetter(rect: $rect))
      // <<<
    }
}

取得したいViewの位置とサイズが分かったので、最前面の画面が取得できるUIApplication.shared.windows[0].rootViewController からUIViewを取得します。

UIViewからUIImageに変換するのは従来のやり方でUIGraphicsImageRendererを利用します。
UIViewをextensionしてgetImageメソッドを追加します。

extension UIView {
    func getImage(rect: CGRect) -> UIImage {
        let renderer = UIGraphicsImageRenderer(bounds: rect)
        return renderer.image { rendererContext in
            layer.render(in: rendererContext.cgContext)
        }
    }
}

そして、TestPageの必要なタイミングでUIApplication.shared.windows[0].rootViewController?.view!.getImage(rect: self.rect)を呼ぶことでUIImageを取得することができます。

UIApplication.shared.windows[0].rootViewController?.view!.getImage(rect: self.rect)
struct TestPage: View {
    @State private var rect: CGRect = .zero
    //追加 >>>
    @State var uiImage: UIImage? = nil
    // <<<
    var body: some View {
     //追加 >>>
        VStack {
    // <<<
            HStack {
                Image(systemName: "sun.haze")
                    .font(.title)
                    .foregroundColor(.white)
                Text("Hello, World!")
                    .font(.title)
                    .foregroundColor(.white)
            }
            .padding()
            .background(Color.blue)
            .cornerRadius(8)
            .background(RectangleGetter(rect: $rect))
     //追加 >>>
            Button(action: {
                self.uiImage = UIApplication.shared.windows[0].rootViewController?.view!.getImage(rect: self.rect)
            }) {
                Text("Button")
                    .padding()
                    .foregroundColor(Color.white)
                    .background(Color.red)
                    .cornerRadius(8)

            }

            if uiImage != nil {
                VStack {
                    Image(uiImage: self.uiImage!)
                }.background(Color.green)
                    .frame(width: 500, height: 500)
            }
        }
    // <<<
    }
}

スクリーンショット 2019-12-01 15.01.00.png

以上のようになり。ボタンをタップすると

Imageが表示されます。ありがとうございました?‍♂️
スクリーンショット 2019-12-01 14.55.32.png

まとめ

SwiftUIのViewからUIImageを取得するためには
1. View全体を得るためにbackgroundを使う
2. GeometryReaderを使ってCGRectを取得する
3. そのCGRectを使ってUIViewからUIImageを取得する

完成したコード
https://gist.github.com/tsuzukihashi/41fbb0c594e26e317cfcec878e9948f4

問題点

この方法は完全にViewだけをUIImageにしているのではなく、Viewの範囲をとってUIImageに変換しています。
したがって、このViewの上に別のViewがかぶっていた場合でも、かぶった範囲が一緒にUIImageになってしまいます。

参考にした記事URL

iOS:最前面に画面を出す/最前面の画面を知る
SwiftUIの肝となるGeometryReaderについて理解を深め
SwiftUIでChildViewのFrameを取得する

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

cygwinでSwiftを使う方法

はじめに

Windows 7 以降の 64BIT 版 Windows には、64BIT 版の cygwin の中に Apple iOS の標準言語であるところの Swift をインストールして使うことが出来ます。

ただし、予め以下のもののインストールが必要です。

  • cygwin 64BIT バージョン。
  • cygwin 版 clang ver 8 パッケージ。
  • cygwin 版 libiconv パッケージ。x86_64-w64-mingw32 というもの。
  • cygwin 版 libicu60 (6.0.2-1) パッケージ。

インストールすると、少なくとも以下のコマンドが使えるようになります:

  • swiftc : *.swift から cygwin で実行できる native binary を生成するコンパイラ。
    print("Hello World") をコンパイルして実行できることを確認済みです。
    また、LLVM の可読的中間言語(IR) の *.ll を出力できることも確認済みです。
    swiftc は、バックエンドに clang と ld を使っています。

  • swift : 対話式インタプリタ(REPL)。
    1 + 2[ret] と入れると 3 と出ます。

インストール方法

インストール方法を手順を追って説明します。

Win7, 64BIT に 64BIT 版 cygwin をインストール

cygwin の GUI インストーラーを使って、cygwin 公式サイトからインストールします。
以下で cygwin のインストールフォルダは、c:\cygwin64 とします。

その際、インストーラーで以下のパッケージをチェック状態にしておきます。

  1. clang の ver 8 のパッケージ。

  2. libiconv 系パッケージ。x86_64-w64-mingw32 的な名前のもの。これがインストールされると、
    /usr/x86_64-w64-mingw32/sys-root/mingw/lib
    に libiconv.a と libiconv.dll.a がインストールされる。これは、swiftc コンパイラが使う。

  3. Category: libs の Package: libicu60 (6.0.2-1) パッケージ。
    ICU とは、International Components for Unicode のこと。
    これをインストールすると、少なくとも C:\cygwin64\bin に cygicui18n60.dll, cygicuuc60.dll
    がインストールされます。これは、swiftc でコンパイルして生成された実行ファイルが起動されるときに動的ライブラリとして読み込まれます。

Swift のダウンロードと展開

https://swiftforwindows.github.io/news/2018/02/12/Swift-for-Windows-Cygwin-20180212/
https://github.com/tinysun212/swift-windows/releases/tag/swift-4.0.3+cygwin.20180212

にて、cygwin 用に非公式に公開されている swift-4.0.3.cygwin.20180212-bin.tar.gz をダウンロードします。*.7Z 形式の方がサイズがだいぶ小さいのでそちらの方が良いかも知れません。以下では、*.tar.gz の方で説明しておきます。

$ tar -xvzf swift-4.0.3.cygwin.20180212-bin.tar.gz

のようにして展開すると、xxx/usr/bin にコマンド類が、xxx/usr/lib にライブラリが配置されます。
ただし、xxx は、./swift-4.0.3.cygwin.20180212-bin となるはずです。

  • xxx/usr/bin には swift.exe と swiftc.exe が出来ています。
  • swiftc.exe がコンパイラで、swift.exe が対話式インタプリタです。
  • swiftc.exe は、*.ll のような LLVM コードを出力することも出来ます。

パスの設定

上記の xxx/usr/bin のフォルダのフルパスを、PATH 環境変数に追加しておきます。

swift-4.0.3.cygwin.20180212-bin.tar.gz を展開した場所が c:\xxx だった場合、以下の様になります。

$ export PATH=/cygdrive/c/xxx/usr/bin:$PATH

ライブラリパスの設定

clang のリンカ ld がライブラリの *.a, *.o をリンクする際に検索対象にする のパスを指定するため、cygwin の端末の中から、

$ export LIBRARY_PATH=/usr/x86_64-w64-mingw32/sys-root/mingw/lib

とします。

テスト

以下の様なテストプログラムを作っておきます:

HelloWorld.swift
print("Hello World!")

コンパイル

$ swiftc -v HelloWorld.swift

とすると、

Swift version 4.0.3 (swift-4.0.3+cygwin.20180212)
Target: x86_64-unknown-windows-cygnus
/cygdrive/k/Commu/FromOther/cygwin/Swift/swift-4.0.3/usr/bin/swift -frontend -c -primary-file elloWorld.swift -target x86_64-unknown-windows-cygnus -disable-objc-interop -module-name HelloWorld -o /tmp/HelloWorld-1bba48.o
/cygdrive/k/Commu/FromOther/cygwin/Swift/swift-4.0.3/usr/bin/swift-autolink-extract tmp/HelloWorld-1bba48.o -o /tmp/HelloWorld-40659b.autolink
/usr/bin/clang++ -Xlinker -rpath -Xlinker /cygdrive/k/Commu/FromOther/cygwin/Swift/swift-4.0.3/usr/lib/swift/cygwin /tmp/HelloWorld-1bba48.o -L /cygdrive/k/Commu/FromOther/cygwin/Swift/swift-4.0.3/usr/lib/swift/cygwin -lswiftCore --target=x86_64-unknown-windows-cygnus @/tmp/HelloWorld-40659b.autolink -o HelloWorld

と出力されます。

実行テスト

$ ./HelloWorld
Hello World!

となります。

対話式インタプリタ

$ swift

とすると、対話式インタプリタが起動します。

備忘録

もし、shared library が見つからないと言うエラーが出たような場合、

cygcheck ./HelloWorld

とすると、どんな名前の shared library が見つからないかが、stderr に出力されます。ですので、

$ cygcheck ./HelloWorld 1>a 2>b

として、b のファイルをテキストエディタで見ればよいです。それは例えば、

cygcheck: track_down: could not find cygicui18n60.dll

cygcheck: track_down: could not find cygicuuc60.dll

のように出力されます。

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

Windows 上の cygwinでSwift コンパイラ/インタプリタを使う方法

はじめに

Windows 7 以降の 64BIT 版 Windows には、64BIT 版の cygwin の中に Apple iOS の標準言語であるところの Swift をインストールして使うことが出来ます。

ただし、予め以下のもののインストールが必要です。

  • cygwin 64BIT バージョン。

  • cygwin 版 clang ver 8 パッケージ。
    cygwin用のパッケージとしては、ver6, ver8 は有りますが、ver7 は有りません。cygwinパッケージとしてではなく、Windows 用の clang ver7をLLVM公式サイトからインストールしてそこにパスを通している場合は、今回のバージョンの Swift とは恐らくコマンドラインの書き方の一部に互換性が無いようです。よって、必ず ver 8 の clang を使ってください。なお、Swiftにおいて、clang または、clang を実装するためのコアライブラリが、llvm の backend として使われているようです。Swift は、論理的には *.swift を、Swift Intermediate Language (SIL) というSwift 専用の中間言語ファイル *.sil にコンパイルした後、 可読的 LLVM 言語 *.ll に直し、LLVM バイナリ *.bc に直し、native のオブジェクトコード *.o に直し、最後にリンカの ld で native の実行ファイルの *.exe などにリンクする、ということをしています。ただし、ディスク上のファイルを出力せずに一気に *.o に変換することも出来ます。なお、一旦テンポラリディスクファイルを作ってから消しているとは限らず、中間形式はオンメモリで処理されている可能性も有ります。なお *.ll は、オンメモリにすら作られずに、一気に *.bc に直されている可能性があります。

  • cygwin 版 libiconv パッケージ。x86_64-w64-mingw32 というもの。

  • cygwin 版 libicu60 (6.0.2-1) パッケージ。

インストールすると、少なくとも以下のコマンドが使えるようになります:

  • swiftc : *.swift から cygwin で実行できる native binary を生成するコンパイラ。
    print("Hello World") をコンパイルして実行できることを確認済みです。
    また、LLVM の可読的中間言語(IR) の *.ll を出力できることも確認済みです。
    swiftc は、バックエンドに clang と ld を使っています。

  • swift : 対話式インタプリタ(REPL)。
    1 + 2[ret] と入れると 3 と出ます。

インストール方法

インストール方法を手順を追って説明します。

Win7, 64BIT に 64BIT 版 cygwin をインストール

cygwin の GUI インストーラーを使って、cygwin 公式サイトからインストールします。
以下で cygwin のインストールフォルダは、c:\cygwin64 とします。

その際、インストーラーで以下のパッケージをチェック状態にしておきます。

  1. clang の ver 8 のパッケージ。

  2. libiconv 系パッケージ。x86_64-w64-mingw32 的な名前のもの。これがインストールされると、
    /usr/x86_64-w64-mingw32/sys-root/mingw/lib
    に libiconv.a と libiconv.dll.a がインストールされる。これは、swiftc コンパイラが使う。

  3. Category: libs の Package: libicu60 (6.0.2-1) パッケージ。
    ICU とは、International Components for Unicode のこと。
    これをインストールすると、少なくとも C:\cygwin64\bin に cygicui18n60.dll, cygicuuc60.dll
    がインストールされます。これは、swiftc でコンパイルして生成された実行ファイルが起動されるときに動的ライブラリとして読み込まれます。

Swift のダウンロードと展開

https://swiftforwindows.github.io/news/2018/02/12/Swift-for-Windows-Cygwin-20180212/
https://github.com/tinysun212/swift-windows/releases/tag/swift-4.0.3+cygwin.20180212

にて、cygwin 用に非公式に公開されている swift-4.0.3.cygwin.20180212-bin.tar.gz をダウンロードします。*.7Z 形式の方がサイズがだいぶ小さいのでそちらの方が良いかも知れません。以下では、*.tar.gz の方で説明しておきます。

$ tar -xvzf swift-4.0.3.cygwin.20180212-bin.tar.gz

のようにして展開すると、xxx/usr/bin にコマンド類が、xxx/usr/lib にライブラリが配置されます。
ただし、xxx は、./swift-4.0.3.cygwin.20180212-bin となるはずです。

  • xxx/usr/bin には swift.exe と swiftc.exe が出来ています。
  • swiftc.exe がコンパイラで、swift.exe が対話式インタプリタです。
  • swiftc.exe は、*.ll のような LLVM コードを出力することも出来ます。

パスの設定

上記の xxx/usr/bin のフォルダのフルパスを、PATH 環境変数に追加しておきます。

swift-4.0.3.cygwin.20180212-bin.tar.gz を展開した場所が c:\xxx だった場合、以下の様になります。

$ export PATH=/cygdrive/c/xxx/usr/bin:$PATH

ライブラリパスの設定

clang のリンカ ld がライブラリの *.a, *.o をリンクする際に検索対象にする のパスを指定するため、cygwin の端末の中から、

$ export LIBRARY_PATH=/usr/x86_64-w64-mingw32/sys-root/mingw/lib

とします。

テスト

以下の様なテストプログラムを作っておきます:

HelloWorld.swift
print("Hello World!")

コンパイル

$ swiftc -v HelloWorld.swift

とすると、

Swift version 4.0.3 (swift-4.0.3+cygwin.20180212)
Target: x86_64-unknown-windows-cygnus
/cygdrive/k/Commu/FromOther/cygwin/Swift/swift-4.0.3/usr/bin/swift -frontend -c -primary-file elloWorld.swift -target x86_64-unknown-windows-cygnus -disable-objc-interop -module-name HelloWorld -o /tmp/HelloWorld-1bba48.o
/cygdrive/k/Commu/FromOther/cygwin/Swift/swift-4.0.3/usr/bin/swift-autolink-extract tmp/HelloWorld-1bba48.o -o /tmp/HelloWorld-40659b.autolink
/usr/bin/clang++ -Xlinker -rpath -Xlinker /cygdrive/k/Commu/FromOther/cygwin/Swift/swift-4.0.3/usr/lib/swift/cygwin /tmp/HelloWorld-1bba48.o -L /cygdrive/k/Commu/FromOther/cygwin/Swift/swift-4.0.3/usr/lib/swift/cygwin -lswiftCore --target=x86_64-unknown-windows-cygnus @/tmp/HelloWorld-40659b.autolink -o HelloWorld

と出力されます。

実行テスト

$ ./HelloWorld
Hello World!

となります。

対話式インタプリタ

$ swift

とすると、対話式インタプリタが起動します。

備忘録

もし、shared library が見つからないと言うエラーが出たような場合、

cygcheck ./HelloWorld

とすると、どんな名前の shared library が見つからないかが、stderr に出力されます。ですので、

$ cygcheck ./HelloWorld 1>a 2>b

として、b のファイルをテキストエディタで見ればよいです。それは例えば、

cygcheck: track_down: could not find cygicui18n60.dll

cygcheck: track_down: could not find cygicuuc60.dll

のように出力されます。

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

cygwinでSwiftを使う方法。

はじめに

Windows 7 以降の 64BIT 版 Windows には、64BIT 版の cygwin の中に Swift をインストールして使うことが出来ます。

ただし、予め以下のもののインストールが必要です。

  • cygwin 64BIT バージョン。
  • cygwin 版 clang ver 8 パッケージ。
  • cygwin 版 libiconv パッケージ。x86_64-w64-mingw32 というもの。
  • cygwin 版 libicu60 (6.0.2-1) パッケージ。

インストールすると、少なくとも以下のコマンドが使えるようになります:

  • swiftc : *.swift から cygwin で実行できる native binary を生成するコンパイラ。
    print("Hello World") をコンパイルして実行できることを確認済みです。
    また、LLVM の可読的中間言語(IR) の *.ll を出力できることも確認済みです。
    swiftc は、バックエンドに clang と ld を使っています。

  • swift : 対話式インタプリタ(REPL)。
    1 + 2[ret] と入れると 3 と出ます。

インストール方法

インストール方法を手順を追って説明します。

Win7, 64BIT に 64BIT 版 cygwin を、GUI インストーラーを使ってインストールする。

以下で cygwin のインストールフォルダは、c:\cygwin64 とする。

cygwin に、以下のパッケージをインストールしておく:

  • clang の ver 8 のパッケージ。

  • libiconv 系パッケージ。x86_64-w64-mingw32 的な名前のもの。これがインストールされると、
    /usr/x86_64-w64-mingw32/sys-root/mingw/lib
    に libiconv.a と libiconv.dll.a がインストールされる。これは、swiftc コンパイラが使う。

  • Category: libs の Package: libicu60 (6.0.2-1) パッケージ。
    ICU とは、International Components for Unicode のこと。
    これをインストールすると、少なくとも C:\cygwin64\bin に cygicui18n60.dll, cygicuuc60.dll
    がインストールされる。これは、swiftc でコンパイルして生成された実行ファイルが起動されるときに動的ライブラリとして読み込まれる。

Swift の DL とインストール

https://swiftforwindows.github.io/news/2018/02/12/Swift-for-Windows-Cygwin-20180212/
https://github.com/tinysun212/swift-windows/releases/tag/swift-4.0.3+cygwin.20180212

にて、cygwin 用に非公式に公開されている swift-4.0.3.cygwin.20180212-bin.tar.gz を DL する。
例: K:\Commu\FromOther\cygwin\Swift\swift-4.0.3

tar -xvzf swift-4.0.3.cygwin.20180212-bin.tar.gz

のように展開すると、xxx/usr/bin にコマンド類が、xxx/usr/lib にライブラリができます。
ただし、xxx は、./swift-4.0.3.cygwin.20180212-bin となるはずです。

xxx/usr/bin には swift.exe と swiftc.exe が出来ている。

swiftc.exe がコンパイラで、swift.exe が対話式インタプリタである。

swiftc.exe は、*.ll に LLVM コードを出力することも出来る。

パスの設定

上記の xxx/usr/bin のフォルダのフルパスを、PATH 環境変数に追加しておきます。

swift-4.0.3.cygwin.20180212-bin.tar.gz を展開した場所が c:\xxx だった場合、以下の様になります。

$ export LIBRARY_PATH=/cygdrive/c/xxx/usr/bin:$PATH

環境変数の設定

clang のリンカ ld がライブラリの *.a, *.o をリンクする際に検索対象にする のパスを指定するため、cygwin の端末の中から、

$ export LIBRARY_PATH=/usr/x86_64-w64-mingw32/sys-root/mingw/lib

とします。

テスト

以下の様なテストプログラムを作っておく:

HelloWorld.swift
print("Hello World!")

コンパイル

$ ./swiftc -v HelloWorld.swift

とすると、

Swift version 4.0.3 (swift-4.0.3+cygwin.20180212)
Target: x86_64-unknown-windows-cygnus
/cygdrive/k/Commu/FromOther/cygwin/Swift/swift-4.0.3/usr/bin/swift -frontend -c -primary-file elloWorld.swift -target x86_64-unknown-windows-cygnus -disable-objc-interop -module-name HelloWorld -o /tmp/HelloWorld-1bba48.o
/cygdrive/k/Commu/FromOther/cygwin/Swift/swift-4.0.3/usr/bin/swift-autolink-extract tmp/HelloWorld-1bba48.o -o /tmp/HelloWorld-40659b.autolink
/usr/bin/clang++ -Xlinker -rpath -Xlinker /cygdrive/k/Commu/FromOther/cygwin/Swift/swift-4.0.3/usr/lib/swift/cygwin /tmp/HelloWorld-1bba48.o -L /cygdrive/k/Commu/FromOther/cygwin/Swift/swift-4.0.3/usr/lib/swift/cygwin -lswiftCore --target=x86_64-unknown-windows-cygnus @/tmp/HelloWorld-40659b.autolink -o HelloWorld
となる。

対話式インタプリタ

$ ./swift

とすると、対話式インタプリタが起動する。

備忘録

もし、shared library が見つからないと言うエラーが出たような場合、

cygcheck ./HelloWorld

とすると、どんな名前の shared library が見つからないかが、stderr に出力される。なので、

$ cygcheck ./HelloWorld 1>a 2>b

として、b のファイルをテキストエディタで見ればよい。それは例えば、

cygcheck: track_down: could not find cygicui18n60.dll

cygcheck: track_down: could not find cygicuuc60.dll

のように出力される。

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

修羅の道 to iOSの電波強度取得

概要

2019年も残りあとわずか!
あと少しで、令和初の年越しを体験できそうですね。
そんな2019/12/1の、ユアマイスターアドベントカレンダー1日目です。
https://qiita.com/advent-calendar/2019/yourmystar
この記事では、iOSの電波強度を取得するために色々と調べたことをまとめます。
この記事の最新のiOSバージョンとは、iOS 13.2.3のことを指します。

道のり

以前は、iOSの電波強度を取得するために statusBar から情報を取得することができていました。
しかし、最新のiOSバージョンではは statusBar に signalStrength or RSSI の値は保有していないことがわかりました。。。

statusBarとは?

statusBar とはiPhoneの上部にある電波マークや電池のマークがある部分のことを言います。
今現在もアプリからstatusBarの色を変更させたり、wifiに接続されているかということは確認ができます。
最新のiOSバージョンでも、アプリからではなく端末からだとRSSI値が確認できます。その方法は下記の参考記事にあります。

現在、アプリから電波強度を取得するには

色々と探した結果、statusBarから情報を取得する以外に、NEHotspotHelperを用いる方法があるようです。 NEHotspotHelperはXcodeが用意してくれているAPIでSSIDの情報などを得ることができます。

注意点

NEHotspotHelperでSSIDの情報から電波強度を取得できると言いましたが注意点があります。

其の1 ユーザの許可が必要

SSIDの情報から位置情報などがわかるためユーザに許可を得る必要があります。こちらは、info.plistに以下を追加すればOKです。

<key>NSExtension</key>
<dict>
    <key>NSExtensionPointIdentifier</key>
    <string>com.apple.networkextension.app-proxy</string>
    <key>NSExtensionPrincipalClass</key>
    <string>MyCustomAppProxyProvider</string>
</dict>

其の2 無料アカウントではダメ

Xcodeの無料アカウントでは使えないAPIのようです。Apple Developer Programに登録して年間1万円ちょい支払う必要があります。

其の3 有料ユーザでも使えるかわからない?

スクリーンショット 2019-12-01 11.53.19.png
自分用のアプリとして作っているのでお金払って使えなかったら嫌だなと思い、Xcodeのサポートに問い合わせてみました。すると、AppleDeveloperProgramに登録したユーザでも使える訳では無いとのこと。使う旨をXcodeに伝え審査を通ると使えるようになるようです。審査の内容については聞くことはできませんでした。意外と1番の落とし穴かもしれません。

参考にした記事

SwiftでWiFi電波強度を取得する
著 p9KYcJ5V さん
https://qiita.com/p9KYcJ5V/items/718ffa057302beff757d

iPhone 電波マークを数字表示にして電波強度を分かり易くしよう♪
https://www.kototoka.com/entry/2014/07/31/apple-iphone-lte-3g-antennamark-suuji

[iOS 10] 接続中のWi-FiのSSIDを取得する
https://dev.classmethod.jp/smartphone/ios-10-cncopysupportedinterfaces/

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

iOSのSpotlight検索に自分のアプリ内コンテンツを検索対象にする方法

Spotlight検索とは・・・?

ホーム画面で下にスワイプしたりすると出るあれです。

スクリーンショット 2019-11-30 14.14.00.png

入力欄にキーワードを入力すると、各アプリのコンテンツなどから、一致するものを探してもらえるので、横断的にアプリのコンテンツを検索できるので便利です。

今回は、このSpotlight検索に、自分のアプリのコンテンツを検索結果に表示させる方法についての記事です。

TodayExtensionやリッチプッシュなどのように、追加で証明書などもいらず、簡単に実装できてアプリへの導線が増やせるので、やってみてはいかがでしょうか?

実装方法

1.準備

プロジェクトにMobileCoreServiceとCoreSpotlightのFrameworkを入れます。
スクリーンショット 2019-11-30 13.11.59.png

2.保存処理を実装する

今回は、例として映画の情報である以下のMovie構造体をSpotlight検索に保存してみましょう。

Movie.swift
struct Movie {

    /// 映画固有の識別番号
    let id: Int = 0

    /// 名前
    let title: String = "スパイダーマン"

    /// あらすじ
    let summary: String = "平凡な少年、ピーター・パーカーは放射能汚染された蜘蛛に噛まれたことで、超人的な能力を得てしまう・・・"

    /// 画像
    let thumbnail: UIImage? = UIImage(named: "spider")

    /// 役者名の配列
    let actorNames: [String] = ["トビー・マグワイア", "キルスティン・ダンスト", "ジェームズ・フランコ"]
}
SpotlightManager.swift
import Foundation
import MobileCoreService
import CoreSpotlight

final class SpotlightManager {

    func save(_ movie: Movie) {
        let attributeSet = CSSearchableItemAttributeSet(itemContentType: kUTTypeData as String)

        // ①タイトル
        attributeSet.title = movie.title

        // ②説明文
        attributeSet.contentDescription = movie.summary

        // ③画像
        attributeSet.thumbnailData = movie.thumbnail.pngData()

        // キーワード(表示されないが、タイトルや説明文に入ってない文言をここに入れておけば、検索した時に引っかかるようになる)
        attributeSet.keywords = movie.actorNames

        /* 
         uniqueIdentifierはAppDelegateで取り出すことができるので、
         Spotlight検索経由でアプリを開いた時のためのURLスキームを入れておく
        */
        let item = CSSearchableItem(
            uniqueIdentifier: "my-app://open/movie?id=\(movie.id)",          
            domainIdentifier: "my-app",
            attributeSet: attributeSet
        )
        CSSearchableIndex.default().indexSearchableItems([item], completionHandler: nil)
    }
}

実際の表示された際は以下の様になります。
スクリーンショット_2019-11-30_14_51_52.png

3.起動時の処理を実装する

AppDelegate.swift
import CoreSpotlight

extension AppDelegate {

    func application(_ application: UIApplication,
                     continue userActivity: NSUserActivity,
                     restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> 
Bool {
        // Spotlightで開かれたかどうかをチェックする
        switch userActivity.activityType {
        case CSSearchableItemActionType:
            return self.openApplicationFromSpotlight(userActivity)
        default:
            return false
        }
    }

    private func openApplicationFromSpotlight(_ userActivity: NSUserActivity) -> Bool {
        // userActivityからURLスキームを取得する
        guard let urlScheme = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String else {
            return false
        }

        // URLスキームを開いた時の処理を実装する
        return true
    }
}

4.保存処理を作る

ViewController.swift
final class ViewController: UIViewController {

    let spotlightManager = SpotlightManager()

    private func show(_ movie: Movie) {
        self.spotlightManager.save(movie)
    }
}

5.確認する

titledescriptionkeywordに設定した文字列を検索欄に入力すると表示されます。
スクリーンショット 2019-11-30 14.19.11.png

部分検索でもヒットします。
スクリーンショット 2019-11-30 14.20.05.png

以上で、ざっくりとした実装は以上です。

おまけ

今回紹介した実装では、Spotlightに保存する画像をローカルのものを使用しましたが、URLで画像を取得して表示することも可能です。
例として画像キャッシュライブラリのNukeを使用した方法を記載しておきます。

SpotlightManager.swift
import Foundation
import MobileCoreService
import CoreSpotlight
import Nuke

func save(_ movie: Movie) {
    ImagePipeline.shared.loadImage(
        with: movie.thumbnailUrl,
        progress: nil) { response, _ in
            guard let thumbnail = response?.image else {
                return
            }

            let attributeSet = CSSearchableItemAttributeSet(itemContentType: kUTTypeData as String)

            attributeSet.title = movie.title

            attributeSet.contentDescription = movie.summary

            attributeSet.thumbnailData = thumbnail.pngData()

            attributeSet.keywords = movie.actorNames

            let item = CSSearchableItem(
                uniqueIdentifier: "my-app://open/movie?id=\(movie.id)",          
                domainIdentifier: "my-app",
                attributeSet: attributeSet
            )
            CSSearchableIndex.default().indexSearchableItems([item], completionHandler: nil)    
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOSのSpotlight検索で自分のアプリ内コンテンツを検索対象にする方法

Spotlight検索とは・・・?

ホーム画面で下にスワイプしたりすると出るあれです。

スクリーンショット 2019-11-30 14.14.00.png

入力欄にキーワードを入力すると、各アプリのコンテンツなどから、一致するものを探してもらえるので、横断的にアプリのコンテンツを検索できるので便利です。

今回は、このSpotlight検索に、自分のアプリのコンテンツを表示させる方法についての記事です。

TodayExtensionやリッチプッシュなどのように、追加で証明書などもいらず、簡単に実装できてアプリへの導線が増やせるので、やってみてはいかがでしょうか?

実装方法

1.準備

プロジェクトにMobileCoreServiceとCoreSpotlightのFrameworkを入れます。
スクリーンショット 2019-11-30 13.11.59.png

2.保存処理を実装する

今回は、例として映画の情報である以下のMovie構造体をSpotlight検索に保存してみましょう。

Movie.swift
struct Movie {

    /// 映画固有の識別番号
    let id: Int = 0

    /// 名前
    let title: String = "スパイダーマン"

    /// あらすじ
    let summary: String = "平凡な少年、ピーター・パーカーは放射能汚染された蜘蛛に噛まれたことで、超人的な能力を得てしまう・・・"

    /// 画像
    let thumbnail: UIImage? = UIImage(named: "spider")

    /// 役者名の配列
    let actorNames: [String] = ["トビー・マグワイア", "キルスティン・ダンスト", "ジェームズ・フランコ"]
}
SpotlightManager.swift
import Foundation
import MobileCoreService
import CoreSpotlight

final class SpotlightManager {

    func save(_ movie: Movie) {
        let attributeSet = CSSearchableItemAttributeSet(itemContentType: kUTTypeData as String)

        // ①タイトル
        attributeSet.title = movie.title

        // ②説明文
        attributeSet.contentDescription = movie.summary

        // ③画像
        attributeSet.thumbnailData = movie.thumbnail.pngData()

        // キーワード(表示されないが、タイトルや説明文に入ってない文言をここに入れておけば、検索した時に引っかかるようになる)
        attributeSet.keywords = movie.actorNames

        /* 
         uniqueIdentifierはAppDelegateで取り出すことができるので、
         Spotlight検索経由でアプリを開いた時のためのURLスキームを入れておく
        */
        let item = CSSearchableItem(
            uniqueIdentifier: "my-app://open/movie?id=\(movie.id)",          
            domainIdentifier: "my-app",
            attributeSet: attributeSet
        )
        CSSearchableIndex.default().indexSearchableItems([item], completionHandler: nil)
    }
}

実際の表示された際は以下の様になります。
スクリーンショット_2019-11-30_14_51_52.png

3.起動時の処理を実装する

AppDelegate.swift
import CoreSpotlight

extension AppDelegate {

    func application(_ application: UIApplication,
                     continue userActivity: NSUserActivity,
                     restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> 
Bool {
        // Spotlightで開かれたかどうかをチェックする
        switch userActivity.activityType {
        case CSSearchableItemActionType:
            return self.openApplicationFromSpotlight(userActivity)
        default:
            return false
        }
    }

    private func openApplicationFromSpotlight(_ userActivity: NSUserActivity) -> Bool {
        // userActivityからURLスキームを取得する
        guard let urlScheme = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String else {
            return false
        }

        // URLスキームを開いた時の処理を実装する
        return true
    }
}

4.保存処理を作る

ViewController.swift
final class ViewController: UIViewController {

    let spotlightManager = SpotlightManager()

    private func show(_ movie: Movie) {
        self.spotlightManager.save(movie)
    }
}

5.確認する

titledescriptionkeywordに設定した文字列を検索欄に入力すると表示されます。
スクリーンショット 2019-11-30 14.19.11.png

部分検索でもヒットします。
スクリーンショット 2019-11-30 14.20.05.png

以上で、ざっくりとした実装は以上です。

おまけ

今回紹介した実装では、Spotlightに保存する画像をローカルのものを使用しましたが、URLで画像を取得して表示することも可能です。
例として画像キャッシュライブラリのNukeを使用した方法を記載しておきます。

SpotlightManager.swift
import Foundation
import MobileCoreService
import CoreSpotlight
import Nuke

func save(_ movie: Movie) {
    ImagePipeline.shared.loadImage(
        with: movie.thumbnailUrl,
        progress: nil) { response, _ in
            guard let thumbnail = response?.image else {
                return
            }

            let attributeSet = CSSearchableItemAttributeSet(itemContentType: kUTTypeData as String)

            attributeSet.title = movie.title

            attributeSet.contentDescription = movie.summary

            attributeSet.thumbnailData = thumbnail.pngData()

            attributeSet.keywords = movie.actorNames

            let item = CSSearchableItem(
                uniqueIdentifier: "my-app://open/movie?id=\(movie.id)",          
                domainIdentifier: "my-app",
                attributeSet: attributeSet
            )
            CSSearchableIndex.default().indexSearchableItems([item], completionHandler: nil)    
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Swift】nilってなんだろう?

nilってなんだろう?

 nilとは、値が存在しないことを示すものです。他の言語ではnillやnoneと言った名前の場合もありますよね。
 多くの言語で、変数や定数の初期化がされていない状態や参照先が存在しない状態を示す値として活用されています。一方で、初期化されていない値や参照先が存在しない値へのアクセスによる実行時エラーを招いてしまうという問題もありました。

Swiftでのnilの扱い

 Swiftでは、nilを許容する特別な型へしかnilを代入できない仕組みになっています。こうすることで、予期せぬところでnilが使用されず、上で述べたような実行時エラーをなくすということを実現しています。
 Swiftでnilを扱える代表的な型は、Optional型です。これは、WrappedのところにIntやStringなどの具体的な型を指定して使用します。Intがnilを扱えないのに対し、Optional型はIntとnilの両方を扱えます。下に例を示します。

optional.swift
let number: Int //Int型なのでnilは入れられない
let optionalNumber: Optional<Int> //nilを入れられる

number = nil //コンパイルエラー
optionalNumber = nil //OK

まとめ

 Swiftでは、基本的にはnilを扱えない仕様となっています。この仕様により、コンパイル時に値の有無を判断しています。これにより、意図していないところでnilが発生し、プログラムやアプリがクラッシュすることを未然に防いでいると言うことができるのではないでしょうか?

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

[Swift]新規プロジェクト起動するとCould not find a storyboard named ‘Main’ in bundle NSBundleでひたすら動かない

状況

Main.storyboardを消してHome.storyboardとかを新しく作ったら

Could not find a storyboard named ‘Main’ in bundle NSBundle

がでてクラッシュする

ポイント

  • Home.storybordにあるViewControllerと(例えば)HomeVCの紐付けはできている
  • Is Initial View Controllerはチェック入れてる
  • Main storyboard file base nameを消す or Main->Homeに変更も両方やった スクリーンショット 2019-12-01 0.22.00.png
  • Copy Bundle Resourcesにもドラッグした スクリーンショット 2019-12-01 0.23.56.png

対策

  • 初期で生成されるMain.storyboardは残したままプロジェクトの作成を続ける ->何らかの形で初期で生成したstoryboardの情報や参照状況が残っているからバグが起こる?
  • 名前を変えたい場合はMain.storyboard残したまま名前だけ変える or Main.storyboardの削除時にMove to trashせずにRemove referenceするにとどめる

参考

Could not find a storyboard named 'Main' in bundle NSBundle 対処方法 - Qiita
"Could not find a storyboard named 'Main' in bundle NSBundle" のエラーを吐いてしまい実機で動かない | makotton.com
xibをロードしようとしたらアプリ落ちる問題に少しハマったのでメモ - Qiita

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

SwiftUI と UIKit 混合環境で開発を行うときの tips 集

はじめに

AkkeyTV という動画視聴アプリはこの記事執筆段階で UIKit と SwiftUI を用いて開発されています。
iOS13 未満は UIKit で構築された UI をロードし、 iOS13 以降では SwiftUI で構築された UI をロードするため、分岐処理が必要になります。また、1画面のために2パターンの UI 設計をする必要があるため、開発効率的にはよくありません。

ただ、 SwiftUI で設計する能力は今後必要になるため、勉強も兼ねて UIKit と SwiftUI の混合環境を選んでいます。
この混合環境を実現する上で学んだことをここにまとめたいと思います。

SwiftUI.framework の weak link

Carthage などでライブラリを追加した場合、 Xcode でパスの設定も行うと思います。Apple が提供する framework に関してはこれを行わなくても import して呼び出すことができます。これが Auto Link です。
これが iOS13 未満の端末でアプリを起動した際にも動いてしまうため、 SwiftUI が実装されているアプリはクラッシュしてしまいます。

解決方法ですが、明示的に SwiftUI.frameworkweak link として設定してあげることで対応可能です。
Build Phases > Link Binary With Libraries > SwiftUI.framework [Optional]

UIHostingController

SwiftUI で設計されたものを UIKit 側から触るときは UIHostingController を使用します。
SwiftUI で設計された SwiftUIView() というものを定義している場合、以下のように画面遷移を記述することができます。

guard #available(iOS 13.0.0, *) else { return }
let vc = UIHostingController(rootView: SwiftUIView())
self.present(vc, animated: true)

また、「すでに表示されてたら画面遷移処理を行わない」といった処理を行うときも UIHostingController を用いて対応可能です。

guard #available(iOS 13.0.0, *) else { return }
let vc = UIHostingController(rootView: SwiftUIView())
let frontVC = ...

if !(frontVC is UIHostingController<SwiftUIView>) {
    self.present(vc, animated: true)
}

*Representable

UIKit で設計されたものを SwiftUI 側から触るときは *Representable に準拠させてあげます。具体的には、 UIViewControllerUIViewControllerRepresentable に、 UIViewUIViewRepresentable に準拠させてあげます。
UIKit で設計された AkkeyView() というものを定義している場合、以下のようにプレビューさせることができます。

import UIKit
#if canImport(SwiftUI) && DEBUG
import SwiftUI

@available(iOS 13, *)
struct AkkeyViewPreviews: PreviewProvider {
    static var previews: some View {
        Group {
            AkkeyView()
                .previewLayout(.fixed(width: 414, height: 100))
                .previewDevice(PreviewDevice(rawValue: "iPhone XS Max"))
        }
    }

    static var platform: PreviewPlatform? = .iOS
}

@available(iOS 13, *)
extension AkkeyView: UIViewRepresentable {
    typealias UIViewType = AkkeyView

    func makeUIView(context: Context) -> AkkeyView {
        return .init()
    }

    func updateUIView(_ uiView: AkkeyView, context: Context) {
        // Make parameter change for preview
    }
}
#endif

さいごに

SwiftUI 導入を手助けする方法をいくつか考えていますので、よかったら参考にしてみてください。

AkkeyLab/AutoPreviewable
UIKit で設計された画面をプレビューするクラスを自動生成させる

AkkeyLab/XcodePreviewsTemplate
UIKit のクラスファイルを作成するときにプレビューするクラスを自動生成させる

AkkeyLab/StoryboardPreviewsBySwiftUI
実際のプロダクトに XcodePreviews を導入してみた

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