20200527のSwiftに関する記事は14件です。

【SwiftUI】基本的なモディファイアの種類と使い方③(画像編)

SwiftUIのレイアウトに画像を表示するために必要なImageビュー

システムアイコン

システムから取得した画像をアイコンを加工する時、テキストビューで用いるようなモディファイアが使用される

Image(systemName: "circle.grid.hex.fill")
    .font(.largeTitle)
    .foregroundColor(.orange)

resizableモディファイア

画像をリサイズ
倍率を間違えると画像が縦や横に歪むことがある

Image("hoge_image")
    .resizable()
    .frame(width: 300.0 ,height: 120.0)

scaledToFitモディファイア

縦横比を維持しながらビューサイズにあうようにリサイズ

Image("hoge_image")
    .resizable()
    .scaredToFit()
    .frame(width:300,height:120)

scaledToFillモディファイア

縦横比を維持しながらいずれかの部分がビューサイズいっぱいになるように伸縮
ビューサイズからはみだすことがある

Image("hoge_image")
    .resizable()
    .scaredToFill()
    .frame(width:300,height:120)

clippedモディファイアでビューサイズにクリップできる

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

Swiftの構造体はC++に渡せるよって話

! C++とSwift間で構造体を渡すことは保証されておらず、メモリレイアウトが異なる場合があるそうです。

この記事は参考程度に

背景

Swiftは比較的高速な言語ですが、やっぱり速度面でC++を使いたくなることはあります。

しかし、Swiftが直接サポートしているのはCからのブリッジのみです。なので、
C++の構造体をObjCでラップして... 
なんて形で渡すことが多いです。

でもこれだとかなりオーバヘッドが大きくて(特にObjCの動的ディスパッチあたりが)、結局C++を使った意味は...?となることが多いです。

なので、実はSwiftの構造体くらいならC++に直接渡せるよって話です。

実験

Swiftでこんな構造体を作ります。

struct Point {
    var x: Double
    var y: Double
}
struct Size {
    var width: Double
    var height: Double
}
struct Rect {
    var origin: Point
    var size: Size
}

それをポインタにして、cppcallに投げます。

var frame = Rect(origin: Point(x: 100, y: 200), size: Size(width: 300, height: 400))

withUnsafeMutablePointer(to: &frame) {ptr in
    cppcall(UnsafeMutableRawPointer(ptr))
}

次にC++側です。

Swiftで定義した構造体と同じ構造を持つ構造体をC++側で定義します。

struct Point {
    double x;
    double y;
};

struct Size {
    double width;
    double height;
};

struct Rect {
    Point origin;
    Size size;
};

void cppcall(void *swiftStruct) {

    auto casted = (struct Rect*)(swiftStruct);

    std::cout << casted->origin.x << std::endl;
    std::cout << casted->size.height << std::endl;
}

Headerで以下のようにextern "C"します。受け取るのは void*型です。

extern "C" {

void cppcall(void* swiftStruct);

}

これで、実行するとそのままC++にSwiftでインスタンス化した構造体がC++で使えます。

解説のようなもの

これがうまく行くのはSwiftとC++でメモリ上の構造体の扱いが同じだからです。どちらもメンバーを最初から順番にメモリに並べているだけです。(SwiftはOptionalな構造体ではちょっと扱いが変わりますが...)

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

iOSアプリにYouTube動画を埋めこもう2020

iOSアプリにYouTube動画を埋めこんだので、その覚書です。

基本

YouTubeの公式ドキュメントを見ると、YouTube-Player-iOS-HelperというOSSを使うのを推奨されます。
基本WebViewをベースに、いい感じのサイズにしたYouTube動画を埋めこんで、コントローラーをつけるという感じです。

(公式ドキュメント)
Embed YouTube Videos in iOS Applications with the YouTube Helper Library

(わかりやすいチュートリアル)
[Swift] YouTube-Player-iOS-Helper を使って YouTube 動画を再生してみる

2020年に直面した問題

YouTube-Player-iOS-Helperなんですが、メンテナンスされていないっぽいです。
最近AppleからUIWebView完全廃止の連絡があって、WKWebViewに移行したと思うんですが、
YouTube-Player-iOS-HelperはUIWebViewを使っているので、これ使っているとReject理由になります。
(YouTube公式に対応して欲しいですが、その辺はGoogleとAppleなので、あんまり息があってない感じはします)

幸いOSSなので、有志の方がWKWebView移行したライブラリを作ってくれていて、YoutubePlayer-in-WKWebViewがあるので、こちらを使いました。

CocoaPod
pod "YoutubePlayer-in-WKWebView", "~> 0.3.0"

サムネイル画像が自由に調整できない問題

で、ここから実装にあたって苦労したこと。

サムネ画像をバーンってやっぱ表示したかったんですけど、
最初200*200ぐらいでつくろうとしたら、トリミングされた形で表示されました。
YouTubeのサムネイル画像は、下記のサイズで提供されるみたいです。

  • 高クオリティ(480x360) ※ width x height
  • 中クオリティ(320x180)
  • 標準クオリティ(120x90)
  • HQ動画の標準クオリティ(640x480)
  • FULLHDのクオリティ(1920x1080)

基本iPhoneのサイズを意識すると、中クオリティ or 標準クオリティが多いでしょうか。
(iPadが入ってくると、それ以上のサイズもあるかと思われます)
このサイズに合っていないと、それより一つ大きいサイズのサムネをとってきて、よしなにトリミングするっぽいです。
YouTube-Player-iOS-Helper(から派生したYoutubePlayer-in-WKWebView)だと、
たとえば400x200のViewに中クオリティ(320x180)を引き伸ばして表示、みたいなことはできない模様です。

パラメーターが効かない問題

load(withPlaylistId:playerVars:)のplayerVarsでプレイヤーを制御するパラメーターが色々指定できるんですが、
実際やってみると想定どおり調整できませんでした。

YouTube 埋め込みプレーヤーとプレーヤーのパラメータ

まずautoplayは効かなかったです。
アプリ側のライフサイクルとかもあんのかな? と思ってますが、ちょっと謎です。
iv_load_policy(動画アノテーション)も効かなかったですね。
(動画アノテーション:動画の上に出るクリック可能な文字)

playsinline/controlsは効きました。
modestbranding(YouTubeロゴ非表示)は効かなかったんですが、
メソッドをload(withPlaylistId:)にするとなぜかYouTubeロゴが非表示になりました。

この辺の挙動はOSS内部の問題なのか、API側の問題なのか謎です。

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

Xcode11で設定にバージョン・ビルド番号・ライセンスを表記する

Xcode11で設定にバージョン・ビルド番号・ライセンスの表記の仕方を書いていきたいと思います。

Xcode11からVersionとBuildのスクリプトが変わったらしいです。

License表記用のライブラリとしてLicensePlistを使用します。
(すでに、表記するサンプルとしてPKHUDを入れてあります。)

注意

・必ずバージョン番号とビルド番号をメモしておいてください。

[ライセンスの表示]

pod 'LicensePlist'を追加して、ライブラリをアップデート(pod update)します。

.xcworkspaceファイルを開いて、以下の画像の手順で新しいRun Scriptを作成します。
スクリーンショット 2020-05-27 16.03.48.png

作成できたら、Run Scriptとなっている名前を変更しましょう。(別ファイルを作成した時にわかりやすくするため)
ここでは、『License Plist(Run Script)』としています。
スクリーンショット 2020-05-27 16.08.25.png

ここまでできたら、以下のスクリプトをそのままコピペしましょう。

if [ $CONFIGURATION = "Debug" ]; then
${PODS_ROOT}/LicensePlist/license-plist --output-path $PRODUCT_NAME/Settings.bundle --github-token YOUR_GITHUB_TOKEN
fi

スクリーンショット 2020-05-27 16.12.54.png

ここまでで、一旦ビルドします。
問題なければ、そのままアプリが起動するはずです。

Root.plistを作成

次にRoot.plistを作成します。
スクリーンショット 2020-05-27 16.17.27.png

ここまでできたら、ファイル名はRoot.plistで作成します。
スクリーンショット 2020-05-27 16.22.17.png

Root.plistを編集

作成できたら、Root.plistをSouceCodeで表示。
スクリーンショット 2020-05-27 16.27.01.png

表示できたら、以下のコードをコピペ。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>PreferenceSpecifiers</key>
    <array>
        <dict>
            <key>Type</key>
            <string>PSGroupSpecifier</string>
            <key>FooterText</key>
            <string>Copyright</string> <!--コピーライトを追加したい方はCopyrightの中身を変更することでできます-->
        </dict>
        <dict>
            <key>Type</key>
            <string>PSTitleValueSpecifier</string>
            <key>DefaultValue</key>
            <string>1.0.0</string>
            <key>Title</key>
            <string>Version</string>
            <key>Key</key>
            <string>sbVersion</string>
        </dict>
        <dict>
            <key>Type</key>
            <string>PSTitleValueSpecifier</string>
            <key>DefaultValue</key>
            <string>1</string>
            <key>Title</key>
            <string>Build</string>
            <key>Key</key>
            <string>sbBuild</string>
        </dict>
        <dict>
            <key>Type</key>
            <string>PSChildPaneSpecifier</string>
            <key>Title</key>
            <string>Licenses</string>
            <key>File</key>
            <string>com.mono0926.LicensePlist</string>
        </dict>
    </array>
    <key>StringsTable</key>
    <string>Root</string>
</dict>
</plist>

Setting.bundleを追加

プロジェクトファイルの直下に生成されたSettings.bandleをXcodeドラッグ&ドロップして読み込みます。
スクリーンショット 2020-05-27 16.32.18.png

さっき編集したRoot.plistSetting.bundleのなかに移動させましょう。
スクリーンショット 2020-05-27 16.34.48.png

[バージョン・ビルド番号を表示]

新規Run Scriptを作成

先ほどRun Scriptをを作成した時と同じ要領で、もう一つ新しいRun Scriptを作成します。

作成したら、以下のスクリプトをコピペします。

APP_VERSION="$MARKETING_VERSION"
/usr/libexec/PlistBuddy -c "Set :PreferenceSpecifiers:1:DefaultValue ${APP_VERSION}" "${BUILT_PRODUCTS_DIR}/${WRAPPER_NAME}/Settings.bundle/Root.plist"

BUILD_NUMBER="$CURRENT_PROJECT_VERSION"
/usr/libexec/PlistBuddy -c "Set :PreferenceSpecifiers:2:DefaultValue ${BUILD_NUMBER}" "${BUILT_PRODUCTS_DIR}/${WRAPPER_NAME}/Settings.bundle/Root.plist"

今回もわかりやすくするために、名前を変更しました。
スクリーンショット 2020-05-27 16.41.38.png

info.plistを確認

info.plistを先ほどと同じSource Codeで表示して、以下の項目のみ書き換えます。

    <key>CFBundleShortVersionString</key>
    <string>$(MARKETING_VERSION)</string>
    <key>CFBundleVersion</key>
    <string>$(CURRENT_PROJECT_VERSION)</string>

プロジェクトのVersionとBuildを確認

VersionとBuildが空になっていたら、元の値を入力してあげましょう。
スクリーンショット 2020-05-27 17.12.19.png

いざビルド

では、ビルドして設定をみてみましょう!
このように表示できました!
ezgif-1-7519e6669b0c.gif

参考にさせていただいた記事

iOS13の設定アプリにつまずいた - Qiita
Xcode11でのsetting.bundleのバージョン更新スクリプト - Qiita
[iOS][Swift] 設定画面に【アプリのバージョン番号】【CocoaPods導入したライブラリのライセンス】を表示する - Qiita
[iOS] アプリの設定画面にバージョン表記と謝辞を自動で設定する - Developers.io
CocoaPodsでLicensePlistを使う - 野生のプログラマZ

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

俺の嫌いなXcodeが5秒でイチオシ開発環境に

iOS開発者の皆さん、毎日、ハッピーな開発ライフ過ごしてますでしょうか。

楽しい開発には快適な開発環境は欠かせませんが、もちろんiOS開発者の皆さんが使うのはXcodeですよね。
いや、俺はAppCodeという人はそっとタブを閉じましょう。

JetBrains製品、良いですよね。Android Studio も素晴らしい。どんな言語でも同じ様な操作感で。でもちょっともっさりしてるんだよな。
その点、Xcodeは動きはキビキビしていて玄人プログラマー好み。

でも一つ、すごく嫌なところがありました。これさえ直してくれれば最高なのに。

普通、Xcodeって編集する時、複数のタブ開くじゃないですか。自分の場合はこんな感じ。
スクリーンショット 2020-05-27 15.16.35.png
Storyboardと関連のソースファイルを幾つか。そして、デバッグする時は、気になってるところにブレークポイント張ります。

プログラム実行して、さて、気になるところに差し掛かると...
スクリーンショット 2020-05-27 15.16.53.png

あーこのタブでデバッグ状態になって違うファイル開かれたわー、確かに昔そこにブレークポイント張っとったわー。というか、そもそもなんのファイル見てたかも不明だわー。

ということが、多々ありました。(該当のタブで戻るボタン押せば戻るんですが)

ここが本当に嫌いだった。なんでAppleはこれで平気なの?

平気じゃなかった様です。全然普通に回避できました。いつからだろう。

Xcodeの「Preference ー Behaviors」または「Edit Behaviors」を開きましょう。
スクリーンショット 2020-05-27 14.44.20.png

RunningのPausesが、ブレークポイントで一時停止した時の設定の様です。

「Show tab named」のところをチェックして名称を入れましょう。
スクリーンショット 2020-05-27 14.45.22.png

すると、同じことしても、
スクリーンショット 2020-05-27 15.17.20.png

おー、さっきつけた名前で自動でタブが生成されて、編集してたタブはそのままです。

ちなみに、「Play sound」のところを設定すると、一時停止したときにサウンドがなります。同じ様に「Speak announcement using」を設定すると、映画に出てくるハッカーのコンピュータみたいでオススメです。

これ知らなかったでしょ?いや知ってたって!?もしかして、知らなかったの自分だけ??しかしなんで気づかなかったんだろう。

それでは素敵な開発ライフを!

参考

XcodeのBehaviorsを設定してデバッグ時にウインドウを自動で切り替える
https://techracho.bpsinc.jp/wingdoor/2019_12_04/83304


筆者参考

札幌圏でリモート開発、在宅勤務を中心としたシステム開発の会社を経営しています。

ローラハウス

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

SwiftのReverse GenericsというかOpaque Typeの紹介(社内勉強会用)

Swiftとは

Wikipedia より

Swift(スウィフト)は、アップルのiOSおよびmacOS、Linuxで利用出来るプログラミング言語。Worldwide Developers Conference (WWDC) 2014で発表された。アップル製OS上で動作するアプリケーションの開発に従来から用いられていたObjective-CやObjective-C++、C言語と共存することが意図されている。

swift.org Language Guide より

Swift is a type-safe language, which means the language helps you to be clear about the types of values your code can work with.


Type-safe

var id: Int = 123
var name: String = "abc"

name = 456 // Compile error: cannot assign value of type 'Int' to type 'String

var ids: Array<Int> = []
ids.append(id)

ids.append("xyz") // Compile 

安全に、呼び出し側が指定した型で、Arrayを使用することができる (Generics)


Genericsを使わないと、

class IntStack {
    var items = [Int]()
    func push(_ item: Int) {
        items.append(item)
    }
    func pop() -> Int {
        return items.removeLast()
    }
}

var ids = IntStack()
ids.push(id)
ids.push(name) // Compile error: cannot convert value of type 'String' to expected argument type 'Int'

StringStack など、利用する型ごとにクラスが必要?


Genericsを使うと

class Stack<Element> {
    var items = [Element]()
    func push(_ item: Element) {
        items.append(item)
    }
    func pop() -> Element {
        return items.removeLast()
    }
}

var ids = Stack<Int>()
ids.push(id)
ids.push(name) // Compile error: cannot convert value of type 'String' to expected argument type 'Int'

var names = Stack<String>()
names.push(name) 

でも逆に、呼び出し元が決めたい場合も

// これを公開し、実際のstructは隠蔽する
protocol ChatRoom {
    var id: Int { get }
    var name: String { get }
}

// DM: チャットルーム名は参加者名のカンマ区切り、
fileprivate struct DMChat: ChatRoom {
    var id: Int
    var name: String { memberNames.joined(separator: ", ") }
    var memberNames: Array<String>
}

// チーム: 名前やアバターアイコンを持つ
fileprivate struct TeamChat: ChatRoom {
    var id: Int
    var name: String
    var memberIDs: Array<Int>
    var avatarIcon: String
}

func loadDMChat(id: Int) -> ChatRoom {
    return DMChat(id: id, memberNames: ["Taro"])
}

func loadTeamChat(id: Int) -> ChatRoom {
    return TeamChat(id: id, name: "ACCESS", memberIDs: [100,101,102], avatarIcon: "file")
}

loadDMChat() では ChatRoom として内部の型は隠蔽したいんだけど、実際に返るのは常に DMChat

これは擬似コード
func loadDMChat(id: Int) -> <C: ChatRoom> C  {
    return DMChat(id: id, memberNames: ["Taro"])
}

こんな感じに、Reverse Genericsしたい。


Returning an Opaque Type

-func loadDMChat(id: Int) -> ChatRoom {
+func loadDMChat(id: Int) -> some ChatRoom {
    return DMChat(id: id, memberNames: ["Taro"])

こう書くことで、コンパイル時にこの戻り値は DMChat 型とみなされる。

// Compile error: cannot convert value of type 'some ChatRoom' to specified type 'DMChat'
private let dm: DMChat = loadDMChat(id: 1)

仮に DMChat 型が呼び出し元に見えていたとしても、その型で受け取ることを許可しているわけではない。


何が嬉しいのか

  • 型の隠蔽
  • オーバーヘッドがない
    • どんなオーバーヘッド?

Value type

var name: String = "abc"
print(name) // abc

var name2 = name

name.append("1")
print(name) // abc1
print(name2) // abc
  • 実は、SwiftのStringはStructで、値型
  • 値渡し、つまり、メモリの確保、コピーなどが行われれる
    • 実際には、Copy-On-Writeなど最適化されていはいる

https://docs.swift.org/swift-book/LanguageGuide/ClassesAndStructures.html
In fact, all of the basic types in Swift—integers, floating-point numbers, Booleans, strings, arrays and dictionaries—are value types, and are implemented as structures behind the scenes.

参照型のシャローコピーにより引き起こされる問題(コピー元も変更されてしまう)の解決として、値の変更の容易さや、イミュータブルクラスを都度まるごと作り直すオーバーヘッドを考え、値型がよいという考え方らしい(?)


終わり

参考URL

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

FirebaseCrashlyticsで強制クラッシュさせる時の方法が変わっていたお話

環境

  • Xcode 11.4
  • Swift 5.2
  • FirebaseCrashlytics 4.0.0-beta.5

概要(2020/05/27時点)

FirebaseCrashlyticsで強制クラッシュをさせようとしたら、以下の二点で微妙に詰まったのでメモ書きとして残しています。
- シングルトンインスタンス取得の方法が変わっていた
- 強制クラッシュを起こすメソッドが消えていた

シングルトンインスタンス取得

以前のver

Crashlytics.sharedInstance()

現在のver

Crashlytics.crashlytics()

クラッシュさせる

以前のver

Crashlytics.sharedInstance().crash()

現在のver

公式ドキュメントによると fatalError() を使えとのこと。

fatalError()

引っかかった原因

日本語のドキュメントのみ更新されていませんでした...

Crashlytics 公式ドキュメント(日本語)

Crashlytics 公式ドキュメント(英語)

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

なぜか画面がブラックアウトしたとき

メモ

アプリ作ってたらあるタイミングで急に画面が完全にブラックアウトして何も表示されなくなった。

プロジェクト丸ごと昇天したのかと思ったがそんなことはなかった。

SceneDelegateで予め宣言されているwindow変数を使わずに新しくwindow変数を定義して使っていたせいらしい。

(何が違うんだ、、?)

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

PageViewController(UIViewController)とPageControl

ダウンロード.gif

ツイートのコード公開用。
コードの説明は割愛。
・UIViewControllerを4つ用意
・1つはPageViewControllerDelegate、Datasourceに準拠
(サブクラスはUIViewController)
・残り3つはそれぞれidentifierをつけておく。(表示画面用)
(FirstViewController、SecondViewController、ThirdViewController)

あとは残り3つのUIViewControllerを好きなように
いじる事で好きなUIに設定。

import UIKit

class PagesViewController: UIViewController, UIPageViewControllerDelegate, UIPageViewControllerDataSource {
    var pageViewController:UIPageViewController!
    let pageControl = UIPageControl()

    override func viewDidLoad() {
            super.viewDidLoad()

        pageViewController = UIPageViewController(transitionStyle:.scroll,
        navigationOrientation: .horizontal,
        options: nil)
        pageViewController.dataSource = self
        pageViewController.delegate = self
        pageViewController.setViewControllers([getFirst()], direction: .forward, animated: true, completion: nil)
        pageViewController.view.frame = view.frame
        view.addSubview(pageViewController.view!)


        let position = UIScreen.main.bounds.size
        pageControl.frame = CGRect(x: position.width / 2 - 19.5, y: position.height - 100, width: 39, height: 37)
        pageControl.numberOfPages = 3
        pageControl.currentPage = 0
        pageControl.isUserInteractionEnabled = false
        pageControl.pageIndicatorTintColor = .black
        pageControl.currentPageIndicatorTintColor = .green
        view.addSubview(pageControl)
    }
    func getFirst() -> FirstViewController {
        return storyboard!.instantiateViewController(withIdentifier: "FirstViewController") as! FirstViewController
    }
    func getSecond() -> SecondViewController {
        return storyboard!.instantiateViewController(withIdentifier: "SecondViewController") as! SecondViewController
    }

    func getThird() -> ThirdViewController {
        return storyboard!.instantiateViewController(withIdentifier: "ThirdViewController") as! ThirdViewController
    }
    //右スワイプ時に呼ばれる
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {

        if viewController.isKind(of: SecondViewController.self) {
            return getThird()
        } else if viewController.isKind(of: FirstViewController.self) {
            return getSecond()
        } else {
            return nil
        }

    }
    //左スワイプ時に呼ばれる
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {

        if viewController.isKind(of:SecondViewController.self) {
            return getFirst()
        } else if viewController.isKind(of:ThirdViewController.self) {
            return getSecond()
        } else {
            return nil
        }

    }
    /*スワイプに伴う処理(pagecontrolのcurrentPageの
    indexのインクリメント,デクリメントなど)はここに書く*/
    func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating: Bool, previousViewControllers: [UIViewController], transitionCompleted: Bool) {

        if transitionCompleted {
            if let currentVC = pageViewController.viewControllers?[0] {
                let vcName = String(describing: type(of:currentVC))
                if vcName == "FirstViewController" {
                    self.pageControl.currentPage = 0
                } else if vcName == "SecondViewController" {
                    self.pageControl.currentPage = 1
                } else if vcName == "ThirdViewController" {
                    self.pageControl.currentPage = 2
                } else {
                    print("クラスの取得に失敗しました。")
                }
            }
        }
    }
}

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

Qiita の記事の閲覧数とLGTMの数を表示するiOSアプリを作成

この記事の内容について

Screen Shot 2020-05-26 at 4.54.30 PM.png

Open-Source Source code / オープンソース: https://github.com/mszmagic/Qiita-Contribution-Counter

この記事では、いくつかのヒントや私がアプリで使用した技術について説明しています。どちらかというとケーススタディのようなものです。

  • ローカルデバイス上でユーザーのトークンを保存する
  • 既存のウェブクッキーを使用してモバイルアプリケーションにユーザーをログインさせる
  • URLSession を使用して GET requests を作る
  • Swift でJSONレスポンスを解析する
  • リモートサーバーから画像をロードする

もちろん、ここで私はすでにあるものを再発明しませんでした、いくつかの既存のオープンソースフレームワークを使用しました。記事の次の部分でそれについて紹介します。

Screen Shot 2020-05-26 at 4.54.30 PM.png

概要

Githubと同じように Qiita コントリビューションを表示したかったのです。

Screen Shot 2020-05-26 at 3.59.42 PM.png

そこで次のことができるオープンソースiOSアプリを作りました。

1.各記事の合計読み取り回数と読み取り回数を提供する

2.各記事の合計 LGTMs と views を提供する

3.Githubに似たコントリビューションブロックを表示して、当月内に記事を公開した日を表示する

コード構造

View Controller

Screen Shot 2020-05-26 at 3.59.42 PM.png

https://github.com/mszmagic/Qiita-Contribution-Counter/blob/master/QiitaContributionReport/ViewController.swift

ここで、ユーザーは Qiita APIページに進む をクリックして、新しいAPIトークンを作成できます。Safariのクッキーを自動的に使用することにご注意ください。

ASWebAuthenticationSession

ユーザーの既存のブラウザーセッションのクッキーをここで利用できるよう、ASWebAuthenticationSession を用います。そうすればユーザーは再度ログインしなくてもよくなります。

guard let authURL = URL(string: "https://qiita.com/settings/tokens/new") else { return }
let session = ASWebAuthenticationSession(url: authURL, callbackURLScheme: "")
{ callbackURL, error in
    // Handle the callback.
}
session.presentationContextProvider = self
session.start()

また、presentationContextProvider をセットアップして、 ASWebAuthenticationSession がビューを表示する場所を認識できるようにする必要があります。

/*
 ASWebAuthenticationSession がどこにビューを表示すべきか判断するためです
 */
extension ViewController: ASWebAuthenticationPresentationContextProviding {
    func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
        return view.window!
    }
}

ユーザーのトークンをキーチェーンに保存して、次回ユーザーがトークン値を再度入力する必要がないようにします

通常、アプリは機密情報(トークンなど)をキーチェーンに保存します。キーチェーンの使用に関するAppleの公式ドキュメントは以下のとおりです:https://developer.apple.com/documentation/security/keychain_services

キーチェーンサービスを使用するにはある程度のコードが必要なので、私はGithub上のオープンソースのキーチェーンヘルパーを使用しました。

https://github.com/evgenyneu/keychain-swift

let keychain = KeychainSwift()
keychain.set(tokenTextField.text ?? "", forKey: "qiitaToken")
tokenTextField.text = keychain.get("qiitaToken") ?? ""

userTableView

Screen Shot 2020-05-26 at 3.59.42 PM.png

https://github.com/mszmagic/Qiita-Contribution-Counter/blob/master/QiitaContributionReport/userTableView.swift

リモート画像フェッチ

ここでは、Kingfisher というオープンソースのフレームワークを使用しました。

https://github.com/onevcat/Kingfisher

@IBOutlet weak var profileImageView: UIImageView!
//画像をダウンロードして読み込みます
if let imagePath = profileImagePath,
    let convertedURL = URL(string: imagePath) {
    DispatchQueue.main.async {
        self.profileImageView.kf.setImage(with: convertedURL)
    }
}
self.profileImageView.kf.setImage(with: convertedURL)

requestHelper.swift

https://github.com/mszmagic/Qiita-Contribution-Counter/blob/master/QiitaContributionReport/requestHelper.swift

ここでは、URLSession を使ってリクエストしています:

let sessionConfig = URLSessionConfiguration.default
let session = URLSession(configuration: sessionConfig, delegate: nil, delegateQueue: nil)

guard let URL = URL(string: "https://qiita.com/api/v2/authenticated_user/items") else {
    delegate?.onTaskFailed(reason: "URL convertion failed!")
    return
}

var request = URLRequest(url: URL)
request.httpMethod = "GET"

// Headers

request.addValue("Bearer \(userID ?? "")", forHTTPHeaderField: "Authorization")

/* Start a new Task */
let task = session.dataTask(with: request, completionHandler: { (data: Data?, response: URLResponse?, error: Error?) -> Void in
    if let httpResponse = response as? HTTPURLResponse {
        let statusCode = httpResponse.statusCode
        if error == nil && statusCode >= 200 && statusCode < 400 {
            //成功
            let allItems = try! JSON(data: data!).array
            for item in allItems ?? [] {
                if let id = (item.dictionary?["id"])?.stringValue {
                    self.fetchIndividualArticle(id: id)
                }
            }
            return
        }
    }
    // 失敗
    self.delegate?.onTaskFailed(reason: error?.localizedDescription ?? "Unknown error. Please check your token and try again.")
})

task.resume()
session.finishTasksAndInvalidate()

受信結果を解析:

if let fetchedData = data {
    if let parsedData = try? JSON(data: fetchedData).dictionary {
        //ユーザー名
        let name = parsedData["name"]?.stringValue
        //説明 description
        let description = parsedData["description"]?.stringValue
        //プロフィール画像のURLパス
        let profileImage = parsedData["profile_image_url"]?.stringValue
        //
        completionHandler(profileImage, name, description, nil)
    }
}

ここでは、SwiftyJSON というオープンソースのフレームワークを使用しました。

https://github.com/SwiftyJSON/SwiftyJSON

Githubと同じように Qiita コントリビューションを表示したかったのです。

screenshot1.png

ここでは、LSHContributionView というオープンソースのフレームワークを使用しました。

https://github.com/lucashoeft/LSHContributionView

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

[SwiftUI] ListでOffsetを取得する

SwiftUIのListは、UIKitのTableViewよりも簡単にリストを構築できるようになって嬉しいです。
しかし、offsetが今までのように簡単に取得できなかったので、カスタムListを作ってみました。

使い方

struct ContentView: View {
    @State private var offset: CGFloat = 0

    var body: some View {

        TrackableList(contentOffset: $offset) {

            ForEach(0..<100) { _ in
                Text("offset:\(self.offset)")
            }
        }
    }
}

画面収録 2020-05-27 6.54.03.mov.gif

Offset取得可能なカスタムListを作る

struct TrackableList<Content>: View where Content: View {
    @Binding var contentOffset: CGFloat
    let content: Content

    init(contentOffset: Binding<CGFloat>, @ViewBuilder content: () -> Content) {
        self._contentOffset = contentOffset
        self.content = content()
    }

    var body: some View {
        GeometryReader { outsideProxy in
            List {
                ZStack {
                    GeometryReader { insideProxy in
                        Color.clear
                            .preference(key: ScrollOffsetPreferenceKey.self, value: [outsideProxy.frame(in: .global).minY - insideProxy.frame(in: .global).minY])
                            // Send value to the parent
                    }
                    VStack {
                        self.content
                    }
                }
            }
            .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
                self.contentOffset = value[0]
            }
            // Get the value then assign to offset binding
        }
    }
}

struct ScrollOffsetPreferenceKey: PreferenceKey {
    typealias Value = [CGFloat]

    static var defaultValue: [CGFloat] = [0]

    static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
        value.append(contentsOf: nextValue())
    }
}

Viewの外側と内側にそれぞれGeometryReaderを置き、その差し分を計算することでoffsetを取得しています。

参考

こちらの記事を参考にしました。
以下記事では、Listではなく、ScrollViewのoffsetを取得しています。
horizontalverticalshowIndicatorsなどの設定もできるようになっています。
https://medium.com/@maxnatchanon/swiftui-how-to-get-content-offset-from-scrollview-5ce1f84603ec

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

【SwiftUI】 ListでOffsetを取得する

SwiftUIのListは、UIKitのTableViewよりも簡単にリストを構築できるようになって嬉しいです。
しかし、offsetが今までのように簡単に取得できなかったので、カスタムListを作ってみました。

使い方

struct ContentView: View {
    @State private var offset: CGFloat = 0

    var body: some View {

        TrackableList(contentOffset: $offset) {

            ForEach(0..<100) { _ in
                Text("offset:\(self.offset)")
            }
        }
    }
}

画面収録 2020-05-27 6.54.03.mov.gif

Offset取得可能なカスタムListを作る

struct TrackableList<Content>: View where Content: View {
    @Binding var contentOffset: CGFloat
    let content: Content

    init(contentOffset: Binding<CGFloat>, @ViewBuilder content: () -> Content) {
        self._contentOffset = contentOffset
        self.content = content()
    }

    var body: some View {
        GeometryReader { outsideProxy in
            List {
                ZStack {
                    GeometryReader { insideProxy in
                        Color.clear
                            .preference(key: ScrollOffsetPreferenceKey.self, value: [outsideProxy.frame(in: .global).minY - insideProxy.frame(in: .global).minY])
                            // Send value to the parent
                    }
                    VStack {
                        self.content
                    }
                }
            }
            .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
                self.contentOffset = value[0]
            }
            // Get the value then assign to offset binding
        }
    }
}

struct ScrollOffsetPreferenceKey: PreferenceKey {
    typealias Value = [CGFloat]

    static var defaultValue: [CGFloat] = [0]

    static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
        value.append(contentsOf: nextValue())
    }
}

Viewの外側と内側にそれぞれGeometryReaderを置き、その差し分を計算することでoffsetを取得しています。

参考

こちらの記事を参考にしました。
以下記事では、Listではなく、ScrollViewのoffsetを取得しています。
horizontalverticalshowIndicatorsなどの設定もできるようになっています。
https://medium.com/@maxnatchanon/swiftui-how-to-get-content-offset-from-scrollview-5ce1f84603ec

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

API GatewayでCognitoの未認証ユーザへアクセスを許可する

やりたいこと

Cognitoユーザプールの認証済みユーザ、未認証ユーザだけが呼び出せるAPI Gatewayを作成する。
APIの直接呼び出しは許可しない。

認証プロバイダは使用しないけど、特定のアプリのゲストアクセスも使用できるAPIを作成します。

前提

Xcode:11.4
Amplifyが使用可能
AWSMobileClient:2.13.0
AWSAPIGateway:2.13.0

AWS構成図

Untitled Diagram.png

Cognitoユーザプールの作成

Amplifyを使用してユーザを作成します。

Xcodeプロジェクトディレクトリでコンソールからamplifyを使ってCognitoユーザプールを作成。

amplify add auth

内容は任意で作成して

amplify push

としてCognitoのユーザ作成を行います。

API Gatewayの設定

1.RESTでAPIを作成。Lambdaファンクションの実装はご自由に。
2.コンソールで作成したAPIを選択。
3.メニューから「リソース」を選択し、メソッド(GETなど)を選択。
4.メソッドリクエストの「認可」を"AWS_IAM"を選択。
5.デプロイしてSDKも生成しておく。

ロールにポリシーを設定

1.Cogniteのコンソールに移る。
2.Amplifyで生成したプールIDを選択。
3.フェデレーティッドアイデンティティの画面から右上にあるIDプールの編集を選択。
4.認証されていないIDセクションから「認証されていない ID に対してアクセスを有効にする」をチェック
5.同じ画面で認証されていないロール、されているロールが表示されているので覚えておく。

ロールにポリシーを追加

1.IAMに移動
2.Cognitoの認証、未認証のロールを選択しポリシーをアタッチする。
以下のようなポリシーでAPI Gatewayの呼び出しを許可する。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "execute-api:*"
            ],
            "Resource": [使いたいAPI GatewayのARN名]
        }
    ]
}

Xcode側の実装

結構ハマりどころ。

API Gatewayで生成したSDKをプロジェクトにインポート。BridgingHeaderも。
podでAWSMobileClientとAWSAPIGatewayをインストールする。バージョンは2.13.0を使用した。

Podfile
$awsVersion = '~> 2.13.0'
pod 'AWSMobileClient', $awsVersion
pod 'AWSAPIGateway', $awsVersion

AppDelegateに以下を追加。

AppDelegate.swift
import AWSMobileClient

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

    // AWSMobileClientを初期化
    AWSMobileClient.sharedInstance().initialize { (userState, error) in
        guard error == nil else {
            print("Error initializing AWSMobileClient. Error: \(error!.localizedDescription)")
            return
        }
        print("AWSMobileClient initialized.")
    }

    return true
}

APIを呼び出す処理はこんな感じ。

import AWSAPIGateway
import AWSCognitoIdentityProvider

func testGetFunc(){
    let credentialsProvider = 
    AWSCognitoCredentialsProvider(regionType:.リージョンのenum,  identityPoolId:"プールID")
    let configuration = AWSServiceConfiguration(region:.リージョンのenum, credentialsProvider:credentialsProvider)
    AWSServiceManager.default().defaultServiceConfiguration = configuration

    let client = [API Gatewayで生成したSDKクラス].init(configuration: AWSServiceManager.default().defaultServiceConfiguration)

    client.rootGet().continueWith { (task) -> Any? in
        if let error = task.error {
            print("Error occurred: \(error)")
            // エラー時の処理
            return nil
        }

        // 正常時の処理
        if let result = task.result {
            // task.resultにはSDKクラスになっているので好きなように処理する。
            result.hogeList?.forEach({ (item) in
                print(item.hogeValue)
            })
        }
        return task
    }
}

だいぶ端折りましたがこんな感じです。

確認する

ブラウザから、他のアプリからのアクセスがNG。
今回作成したアプリからのアクセスは正常に戻り値が取得可能になっていることを確認します。

ハマったところ

  • API Gatewayで生成したSDKクラスが全然動かなかった。
  • AWSMobileClient、AWSAPIGatewayは最新版はAPIが変わっているので使い方がわからず。2.13.0にとどめた
  • API GatewayのオーソライザーでCognitoを使うのかと思っていたが違っていた。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

"Could not find a storyboard named ‘Main’ in bundle NSBundle"に遭遇した時

環境

  • macOS Catalina 10.15.4
  • Xcode11.4
  • TargetSDK iOS13.4

いつ遭遇したか

iOSアプリを作る際に、最初に表示したい画面の名前を変えたかったので、Main.storyboardのファイル名をSample.storyboardに変えてビルドすると、画面が描画されずに

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

と表示されてクラッシュした。

Could not find a storyboard named 'Main' in bundle NSBundle 対処方法を参考に、info,plistのMain storyboard file base nameを消したり、Sampleに変えても効果なし。

解決方法

Xcode全体にMainで検索をかけてみると、info.plist内にもう一箇所Storyboardの名前を指定している箇所を見つけた。
スクリーンショット 2020-05-27 0.19.25.png
Application Scene Manifest

Scene Configuration

Application Session Role

Item 0 (Default Configuration)

Storyboard Name

これがMainになっていたので、Sampleに変更すると、無事にビルドできた。
ファイル名変えるだけなのにこんなトラップがあるとは...

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