20191002のSwiftに関する記事は12件です。

Mapbox for iOSでサードパーティ製のスタイルを設定する

はじめに

地図系のフレームワークで有名で、最近ではゼンリンとの提携がニュースになったりしたMapboxはiOS向けにもフレームワークを提供しています。

Mapbox for iOSの導入

ただ地図を表示するだけなら、チュートリアルのとおりとてもシンプルに書けます。

import Mapbox

class ViewController: UIViewController {
  override func viewDidLoad() {
    super.viewDidLoad()

    let url = URL(string: "mapbox://styles/mapbox/streets-v11") //Mapbox公式スタイル
    let mapView = MGLMapView(frame: view.bounds, styleURL: url)
    mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
    mapView.setCenter(CLLocationCoordinate2D(latitude: 59.31, longitude: 18.06), zoomLevel: 9, animated: false)
    view.addSubview(mapView)
  }
}

しかし上記コードのコメントにも書いたとおり、基本的には公式スタイルを設定するように作られています。
スタイルは、.json形式のデータにも関わらず、です。
公式スタイルを参照するのが最もラクですが、そのためにはAPIトークンが必要で、アクセス数に応じて料金が発生します。
ならば、OpenStreetMapなどオープンデータを活用し、公式スタイルを使わなければ良いのではないか?
という事で色々試しました。

サードパーティ製のスタイルを適用する

Mapboxのスタイルは、MGLMapViewの初期化時にstyleURLという引数で設定されます。
つまりURLを渡さなければなりませんが、どこかのサーバーにデータを置いておく、というのはナンセンスです。
という訳で、①好きなデータ等を設定したスタイルを.json形式で作成する、②①のローカルアドレスを取得しMGLMapViewに渡す、という方針になりました。

①:スタイルを.json形式で作成する

Swiftでの.jsonの取り扱い等は本記事では割愛します。
スタイルの.jsonファイル、言い換えると辞書形式のデータ構造は以下のとおりです。

    private var style:[String:Any] = [
        "version":8,
        "sources":[],
        "layers":[],
    ]

versionは8で固定です。sourcesはレイヤーデータの形式等の設定、layersはsourcesのデータをどのように表示するか(色など)の設定となっています。
つまりsourcesとlayersに適切な初期データを与えて、.jsonデータを作成すれば良いわけです。

    mutating func setBasemap(name:String, tileUrlStr:String, attributionUrl:String="", tileSize:Int=256) {
        let sources = [
            name:[
                "type":"raster",
                "tiles":[tileUrlStr],
                "attribution":"<a href='" + attributionUrl + "'>" + name + "</a>",
                "tileSize":tileSize
            ]
        ]

        let layers = [
            [
                "id":name,
                "type":"raster",
                "source":name,
                "minzoom":0,
                "maxzoom":18
            ]
        ]

        self.style["sources"] = sources
        self.style["layers"] = layers
    }

上記のコードは、とあるラスターレイヤーをスタイルに設定するサンプルです。
レイヤーのnameとタイルのtileUrlStrを与えてやればスタイルに追記します。
.jsonファイルの出力については、長くなるのでサンプルだけ貼ります。

    func writeJson(outputDir:String, filename:String) -> URL? {
        let nsHomeDir = NSHomeDirectory()
        let outputPath = nsHomeDir + outputDir + "/" + filename + ".json"

        do {
            let jsonData = try JSONSerialization.data(withJSONObject: self.style, options: .prettyPrinted)
            try jsonData.write(to: URL(fileURLWithPath: outputPath))
            return URL(fileURLWithPath: outputPath)
        } catch {
            print("error")
            return nil
        }
    }

②:①のローカルアドレスを取得しMGLMapViewに渡す

①のローカルアドレスは、writeJson()の返り値です。つまり以下のとおり書けます。

let tmpStyleUrl = msManager.writeJson(outputDir: "/tmp", filename: "style")
mapView = MGLMapView(frame: rect, styleURL: tmpStyleUrl!)

ここで、msManagerとはスタイル全般を取り扱うクラスです。
アプリを起動する度に、writeJson()でiOS内の/tmpにstyle.jsonとして出力しています。
/tmpは、アプリを終了した後のデータの保存は保証されない、一次保存用フォルダです。
今回のような用途にもってこいですね。

さて、これで純正スタイルを設定する必要がなくなりました。
API tokenを削除してみましたが、問題なく動作します。API tokenはMapboxスタイルにアクセスするためのものでした。

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

Xcode11で作成したプロジェクトでは`[UIWindow new]`で生成したWindowが画面に表示されない問題の対処法

Xcode11とUISceneとUIWindowにまつわるトラブルシュートに追われている。
個別のケースに分解してお届け。

現象

  • Xcode11で作成したプロジェクトをiOS13で実行しているとき、UIWindowが表示されない
  • rootViewController持たせて、viewDidLoadにbreak pointを貼ったりすると、動作はしていることが確認できる。

原因

  • Xcode11で作る新規プロジェクトは、Scene Based Lifecycleが有効になっている。
  • Scene Based Lifecycleが有効になっていると、[UIWindow new], [[UIWindow alloc] initWithFrame:] で生成したwindowは View Hierarchyに載らなくなる
    • UIWindowを載せるべきWindowSceneが不明なため

対応

ひとつは、Xcode11で作成したプロジェクトからSceneに関する機能をオミットする方法。

もうひとつは正攻法。(本記事)

[[UIWindow alloc] initWithWindowScene:]で windowを生成する。

そのためには、なんらかの手段によってUIWindowSceneを拾い上げる必要がある。

ボタンを押した時に表示したいとか、特定のViewと同じUIWindowSceneを選択する場合

ViewController.m
@interface ViewController ()
@property (nonatomic) UIWindow *window;
@end

@implementation ViewController
- (IBAction)openWindow:(UIButton *)sender {

    UIWindowScene *scene = sender.window.windowScene;
    UIWindow *window = [[UIWindow alloc] initWithWindowScene: scene];

    // 以下は確認用の見た目調整と表示
    UIViewController *viewController = [UIViewController new];
    viewController.view.backgroundColor = UIColer.greenColer;
    window.frame = CGRectMake(20, 20, 50, 50);
    window.rootViewContoller = viewController;

    self.window = window;
    [self.window makeKeyAndVisible];
}
@end
ViewController.swift
class ViewController: UIViewController {
    var window: UIWindow?

    @IBAction func openWindow(_ sender: UIButton) {

        guard let scene = sender.window?.windowScene { return }
        let window = UIWindow(windowScene: scene)

        // 以下は確認用の見た目調整と表示
        let viewController = UIViewController()
        viewController.view.backgroundColer = .green
        window.frame = CGRect(x:20, y:20, width:50, height:50)
        window.rootViewContoller = viewController

        self.window = window
        self.window?.makeKeyAndVisible();
    }
}

Sceneは一つだけのアプリである場合など、UIApplicationからUIWindowSceneを引っ張りたい場合

ViewController.m
@interface ViewController ()
@property (nonatomic) UIWindow *window;
@end

@implementation ViewController
- (IBAction)openWindow:(UIButton *)sender {

    UIWindowScene *scene = (UIWindowScene*)[[[[UIApplication sharedApplication] connectedScenes] allObjects] first];
    UIWindow *window = [[UIWindow alloc] initWithWindowScene: scene];

    // 以下は確認用の見た目調整と表示
    UIViewController *viewController = [UIViewController new];
    viewController.view.backgroundColor = UIColer.greenColer;
    window.frame = CGRectMake(20, 20, 50, 50);
    window.rootViewContoller = viewController;

    self.window = window;
    [self.window makeKeyAndVisible];
}
@end
ViewController.swift
class ViewController: UIViewController {
    var window: UIWindow?

    @IBAction func openWindow(_ sender: UIButton) {

        guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene { return }
        let window = UIWindow(windowScene: scene)

        // 以下は確認用の見た目調整と表示
        let viewController = UIViewController()
        viewController.view.backgroundColer = .green
        window.frame = CGRect(x:20, y:20, width:50, height:50)
        window.rootViewContoller = viewController

        self.window = window
        self.window?.makeKeyAndVisible();
    }
}

参考URL

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

Xcode11で作成したプロジェクトでは`[UIWindow new]`で生成したWindowが画面に表示されない

Xcode11とUISceneとUIWindowにまつわるトラブルシュートに追われている。
個別のケースに分解してお届け。

現象

  • Xcode11で作成したプロジェクトをiOS13で実行しているとき、UIWindowが表示されない
  • rootViewController持たせて、viewDidLoadにbreak pointを貼ったりすると、動作はしていることが確認できる。

原因

  • Xcode11で作る新規プロジェクトは、Scene Based Lifecycleが有効になっている。
  • Scene Based Lifecycleが有効になっていると、[UIWindow new], [[UIWindow alloc] initWithFrame:] で生成したwindowは View Hierarchyに載らなくなる
    • UIWindowを載せるべきWindowSceneが不明なため

対応

ひとつは、Xcode11で作成したプロジェクトからSceneに関する機能をオミットする方法。

もうひとつは正攻法。(本記事)

[[UIWindow alloc] initWithWindowScene:]で windowを生成する。

そのためには、なんらかの手段によってUIWindowSceneを拾い上げる必要がある。

ボタンを押した時に表示したいとか、特定のViewと同じUIWindowSceneを選択する場合

ViewController.m
@interface ViewController ()
@property (nonatomic) UIWindow *window;
@end

@implementation ViewController
- (IBAction)openWindow:(UIButton *)sender {

    UIWindowScene *scene = sender.window.windowScene;
    UIWindow *window = [[UIWindow alloc] initWithWindowScene: scene];

    // 以下は確認用の見た目調整と表示
    UIViewController *viewController = [UIViewController new];
    viewController.view.backgroundColor = UIColer.greenColer;
    window.frame = CGRectMake(20, 20, 50, 50);
    window.rootViewContoller = viewController;

    self.window = window;
    [self.window makeKeyAndVisible];
}
@end
ViewController.swift
class ViewController: UIViewController {
    var window: UIWindow?

    @IBAction func openWindow(_ sender: UIButton) {

        guard let scene = sender.window?.windowScene { return }
        let window = UIWindow(windowScene: scene)

        // 以下は確認用の見た目調整と表示
        let viewController = UIViewController()
        viewController.view.backgroundColer = .green
        window.frame = CGRect(x:20, y:20, width:50, height:50)
        window.rootViewContoller = viewController

        self.window = window
        self.window?.makeKeyAndVisible();
    }
}

Sceneは一つだけのアプリである場合など、UIApplicationからUIWindowSceneを引っ張りたい場合

ViewController.m
@interface ViewController ()
@property (nonatomic) UIWindow *window;
@end

@implementation ViewController
- (IBAction)openWindow:(UIButton *)sender {

    UIWindowScene *scene = (UIWindowScene*)[[[[UIApplication sharedApplication] connectedScenes] allObjects] first];
    UIWindow *window = [[UIWindow alloc] initWithWindowScene: scene];

    // 以下は確認用の見た目調整と表示
    UIViewController *viewController = [UIViewController new];
    viewController.view.backgroundColor = UIColer.greenColer;
    window.frame = CGRectMake(20, 20, 50, 50);
    window.rootViewContoller = viewController;

    self.window = window;
    [self.window makeKeyAndVisible];
}
@end
ViewController.swift
class ViewController: UIViewController {
    var window: UIWindow?

    @IBAction func openWindow(_ sender: UIButton) {

        guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene { return }
        let window = UIWindow(windowScene: scene)

        // 以下は確認用の見た目調整と表示
        let viewController = UIViewController()
        viewController.view.backgroundColer = .green
        window.frame = CGRect(x:20, y:20, width:50, height:50)
        window.rootViewContoller = viewController

        self.window = window
        self.window?.makeKeyAndVisible();
    }
}

参考URL

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

【Swift】基本事項の意味確認

・クラス メソッドやプロパティの集合体で一つの塊
・メソッド 関数のこと
・プロパティ 定数や変数のこと
・インスタンス 何らかの値が入った変数や定数のこと
・インスタンス化 実際に使えるようにすること

例えるとクラスは洋式の部屋、メソッドやプロパティは家具(概念として)、インスタンスは実際に部屋に置いてある家具、インスタンス化は実際に家具を使用すること。

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

MVVM用のXcodeのテンプレート作ってみた

最近RxSwiftを使ったMVVMを勉強してるのですが毎回ViewController作って、ViewModel作って、連結部分書いてってやるの流石に面倒になってきたのでXcodeのテンプレート作ることにしました。

既存のテンプテートはここにあるみたいです。
/Applications/Xcode.app/Contents/Developer/Library/Xcode/Templates/File\ Templates/
1から作るのはめんどくさそうなので一番シンプルそうなSwiftファイルのテンプレートをコピーしていじっていこうと思います。

Customというディレクトリ名で作ってみます。

sudo mkdir /Applications/Xcode.app/Contents/Developer/Library/Xcode/Templates/File\ Templates/Custom/

そして既存のSwiftファイルのテンプレートをコピーしてくる、ついでMVVM.xctemplateにリネーム

sudo cp -r /Applications/Xcode.app/Contents/Developer/Library/Xcode/Templates/File\ Templates/Source/Swift\ File.xctemplate /Applications/Xcode.app/Contents/Developer/Library/Xcode/Templates/File\ Templates/Custom/MVVM.xctemplate

確認してみます。

open /Applications/Xcode.app/Contents/Developer/Library/Xcode/Templates/File\ Templates/

こんな感じになってたらOKです。
スクリーンショット 2019-10-02 12.27.55.png

___FILEBASENAME___.swiftを編集していきます。
一回の操作でViewControllerとViewModelを作りたかったので自分はこんな感じにしました。
スクリーンショット 2019-10-02 11.41.53.png

___VARIABLE_NAME___ViewController.swift
//___FILEHEADER___

import UIKit
import RxSwift
import RxCocoa

class ___VARIABLE_NAME___ViewController: UIViewController {

    private var disposeBag = DisposeBag()
    private let viewModel = ___VARIABLE_NAME___ViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()
        configure()
    }

    private func configure() {
        //outputs

        //inputs

        viewModel.inputs.configure()
    }
}

___VARIABLE_NAME___ViewModel.swift
//___FILEHEADER___

import Foundation
import RxSwift
import RxCocoa

protocol ___VARIABLE_NAME___ViewModelInputs {
    func configure()
}

protocol ___VARIABLE_NAME___ViewModelOutputs {
}

protocol ___VARIABLE_NAME___ViewModelType {
    var inputs: ___VARIABLE_NAME___ViewModelInputs { get }
    var outputs: ___VARIABLE_NAME___ViewModelOutputs { get }
}

final class ___VARIABLE_NAME___ViewModel: ___VARIABLE_NAME___ViewModelType, ___VARIABLE_NAME___ViewModelInputs, ___VARIABLE_NAME___ViewModelOutputs {

    //Properties
    private var disposeBag = DisposeBag()
    var inputs: ___VARIABLE_NAME___ViewModelInputs { return self }
    var outputs: ___VARIABLE_NAME___ViewModelOutputs { return self }

    //Functions
    func configure() {
    }
}
TemplateInfo.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Kind</key>
    <string>Xcode.IDEFoundation.TextSubstitutionFileTemplateKind</string>
    <key>Description</key>
    <string>An empty Swift file.</string>
    <key>Summary</key>
    <string>An empty Swift file</string>
    <key>SortOrder</key>
    <string>30</string>
    <key>AllowedTypes</key>
    <array>
        <string>public.swift-source</string>
    </array>
    <key>DefaultCompletionName</key>
    <string>File</string>
    <key>MainTemplateFile</key>
    <string>___FILEBASENAME___.swift</string>
    <!-- 追加ここから -->
    <key>Options</key>
    <array>
        <dict>
            <key>Identifier</key>
            <string>NAME</string>
            <key>Required</key>
            <true/>
            <key>Name</key>
            <string>名称を入力してください。 Name:</string>
            <key>Description</key>
            <string>「Hoge」と入力するとHogeViewController,HogeViewModelが生成されます。</string>
            <key>Type</key>
            <string>text</string>
            <key>Default</key>
            <string>Hoge</string>
        </dict>
    </array>
    <!-- 追加ここまで -->
</dict>
</plist>

変数の利用方法などこちらのサイト様を参考にさせていただきました。

設定は以上になります。確認してみましょう。
New File...からの
スクリーンショット 2019-10-02 12.01.49.png
スクリーンショット 2019-10-02 12.02.06.png
↓↓↓ここで決めたファイル名は反映されないので適当にEnter押しちゃってください
スクリーンショット_2019_10_02_12_06.png
スクリーンショット 2019-10-02 12.02.37.png
スクリーンショット 2019-10-02 12.02.50.png

良い感じに出来てますね!
気がかりはファイル名を決める画面が全く機能してないところですね、できればスキップしたい、、、
なにか回避策があればコメントいただけると嬉しいです。

今回のコードは↓↓↓にアップしてます。
https://github.com/akasasan454/Xcode_template

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

【Swift】WKWebViewでピンチによるズームを無効にする

import UIKit
import WebKit

class WebViewController: UIViewController {

    @IBOutlet weak var webView: WKWebView!

    override func viewDidLoad() {
        super.viewDidLoad()
        guard let url: URL = Bundle.main.url(forResource: "index", withExtension: "html") else { return }
        webView.scrollView.delegate = self
        webView.loadFileURL(url, allowingReadAccessTo: url)
//        webView.scrollView.pinchGestureRecognizer?.isEnabled = false
//        ↑↑↑この時点で書いてもpinchGestureRecognizerがnilなので意味ない
    }
}

extension WebViewController: UIScrollViewDelegate {
    func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
        scrollView.pinchGestureRecognizer?.isEnabled = false
//      ↑↑↑delegateの中で書いてやると良い
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Macアプリ初心者:Realm で削除されたコレクションに対して自動でUIに反映

前回作成したプロジェクトではRealmで更新された情報を自動的にアップデートするための処理を実装してみましたが、今回はにRealmを使ってみましたが、値が更新された時にUIも連動して更新されるようにしてみました。
Watchリスト風にしてみました。次回は追加機能を作ります。

完成イメージ

「×」ボタンで Watch List から削除されます。
完成イメージ

環境

  • macOS Mojave:10.14.6
  • Xcode:11.0

データの更新イメージ

Watchリスト風にするために、前回作成した時からデータ構造を変更しています。
Kobito.WuChKe.png
株価情報をマスターデータにしてWatchリストへの表示/非表示をフラグで管理します。

データクラスの変更

データ構造を変更

Watchリストの表示/非表示をフラグとして、isWatchList を追加しています。

StockInfo.swift
class StockInfo: Object {
    @objc dynamic var name = ""
    @objc dynamic var price = -1
    @objc dynamic var status = StockStatus.flat.rawValue
    @objc dynamic var isWatchList = true
    @objc dynamic var createdAt = Date()
    @objc dynamic var updatedAt = Date()
}

データ構造変化に伴い処理の追加と変更

isWatchList の更新用の関数を追加

StockInfo.swift
extension StockInfo {
    /// watch list の表示/非表示を切り替える
    func update(isWatchList: Bool) {
        let realm = try! Realm()
        try! realm.write {
            self.isWatchList = isWatchList
            self.updatedAt = Date()
        }
    }

検索用の関数にWatchリストの表示/非表示をフラグを指定できるように修正

StockInfo.swift
    /// 企業名で部分一致検索(空の場合は全件取得)
    class func objects(isWatchList: Bool? = true, searchValue: String = "") -> Results<StockInfo> {
        var result = try! Realm().objects(StockInfo.self)
        let searchValue = searchValue.trimmingCharacters(in: .whitespaces)
        if let isWatchList = isWatchList {
            result = result.filter("isWatchList == %@", isWatchList)
        }
        if searchValue != "" {
            result = result.filter("name CONTAINS %@", searchValue)
        }

        return result.sorted(byKeyPath: "createdAt", ascending: true)
    }
}

Watchリストからの削除用UIを作成

カスタムセルのイメージ

以前に作成したセルの右側に新しくカスタムViewとNSButtonを配置してます。
Kobito.BwLoZb.png

×ボタンクリック時に表示フラグをオフ

以前のプロジェクトからリファクタリングして、株価情報Objectをカスタムセルに渡して株価情報の描画と更新処理を行うようにしてます。
データオブジェクトをUIクラスで保持するのはどうなんだって話もありますが、わかりやすいのでこんな感じで作ってしまってます。

CustomNSTableCellView.swift
class CustomNSTableCellView: NSTableCellView {
    // MARK: - public variables
    var stockInfo: StockInfo? {
        didSet {
            guard let stockInfo = self.stockInfo else {
                return
            }
            self.companyName.stringValue = stockInfo.name
            self.stockImage.image = stockInfo.stockStatus.image
            self.companyName.textColor = stockInfo.stockStatus.color
            self.stockImage.contentTintColor = stockInfo.stockStatus.color

            let formatter = NumberFormatter()
            formatter.numberStyle = .decimal
            let commaString = formatter.string(from: NSNumber(value: stockInfo.price))!
            self.stockPrice.stringValue = commaString
        }
    }

    // MARK: - IBAction
    @IBAction func actionDelete(_ sender: NSButton) {
        self.stockInfo?.update(isWatchList: false)
    }
}

カスタムセルのI/Fを変更したのでそれに合わせて、ViewController 側も変更

ViewController.swift
extension ViewController: NSTableViewDataSource, NSTableViewDelegate {

    // MARK: - NSTableViewDelegate
    func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
        let result = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "MyView"), owner: self) as! CustomNSTableCellView

        // Set the stringValue of the cell's text field to the nameArray value at row
        result.stockInfo = self.stockValues[row]

        // Return the result
        return result
    }
}

ソースコード

展開して確認
ViewController.swift
import Cocoa
import RealmSwift

class ViewController: NSViewController {

    // MARK: - IBOutlet
    @IBOutlet private weak var tableView: NSTableView! {
        didSet {
            self.tableView.dataSource = self
            self.tableView.delegate = self

            self.tableView.headerView = nil
            self.tableView.rowHeight = 88
        }
    }
    @IBOutlet private weak var countTextField: NSTextField!


    // MARK: - private variables
    private var stockValues = StockInfo.objects()
    private var timer: Timer? = nil
    private var notificationToken: NotificationToken? = nil

    // MARK: - override variables
    override var representedObject: Any? {
        didSet {
            // Update the view, if already loaded.
        }
    }

    // MARK: - override func
    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
        // デバッグ用にデータ作成
        StockInfo.debugInitData()
        // 株価情報をViewに反映
        self.updateStockValuesCount()

        // Resultsの通知を監視します
        notificationToken = self.stockValues.observe { [weak self] (changes: RealmCollectionChange) in
            guard let tableView = self?.tableView else { return }
            switch changes {
            case .initial:
                // Results are now populated and can be accessed without blocking the UI
                tableView.reloadData()
            case .update(let collectionType, let deletions, let insertions, let modifications):
                // Resultsに変更があったので、UITableViewに変更を適用します
                print(collectionType)
                tableView.beginUpdates()
                tableView.insertRows(at: IndexSet(insertions), withAnimation: .effectFade)
                tableView.removeRows(at: IndexSet(deletions), withAnimation: .effectFade)
                tableView.reloadData(forRowIndexes: IndexSet(modifications), columnIndexes: IndexSet(integer: 0))
                tableView.endUpdates()
            case .error(let error):
                // バックグラウンドのワーカースレッドがRealmファイルを開く際にエラーが起きました
                fatalError("\(error)")
            }
            // 件数を更新
            self?.updateStockValuesCount()
        }
        self.timer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true, block: { (timer) in
            StockInfo.debugRandomUpdate(stockValues: self.stockValues)
        })
    }

    deinit {
        self.notificationToken?.invalidate()
        self.timer?.invalidate()
    }


    // MARK: - private func

    private func updateStockValuesCount() {
        let numberFormatter = NumberFormatter()
        numberFormatter.numberStyle = .decimal
        let commaCount = numberFormatter.string(from: NSNumber(value: self.stockValues.count))!
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "HH:mm:ss"
        let lastUpdatedString = dateFormatter.string(from: Date())
        self.countTextField.stringValue = "\(commaCount)件 (last updated:\(lastUpdatedString))"
    }


    // MARK: - actions

    @IBAction func actionSearchField(_ sender: NSSearchField) {
        self.stockValues = StockInfo.objects(searchValue: sender.stringValue)
        self.tableView.reloadData()
        self.updateStockValuesCount()
    }    

}

extension ViewController: NSTableViewDataSource, NSTableViewDelegate {

    // MARK: - NSTableViewDataSource
    func numberOfRows(in tableView: NSTableView) -> Int {
        return self.stockValues.count
    }


    // MARK: - NSTableViewDelegate
    func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
        let result = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "MyView"), owner: self) as! CustomNSTableCellView

        // Set the stringValue of the cell's text field to the nameArray value at row
        result.stockInfo = self.stockValues[row]

        // Return the result
        return result
    }
}
CustomNSTableCellView.swift
import Cocoa

class CustomNSTableCellView: NSTableCellView {

    // MARK: - IBOutlet
    @IBOutlet private weak var stockImage: NSImageView!
    @IBOutlet private weak var companyName: NSTextField!
    @IBOutlet private weak var stockPrice: NSTextField!


    // MARK: - public variables
    var stockInfo: StockInfo? {
        didSet {
            guard let stockInfo = self.stockInfo else {
                return
            }
            self.companyName.stringValue = stockInfo.name
            self.stockImage.image = stockInfo.stockStatus.image
            self.companyName.textColor = stockInfo.stockStatus.color
            self.stockImage.contentTintColor = stockInfo.stockStatus.color

            let formatter = NumberFormatter()
            formatter.numberStyle = .decimal
            let commaString = formatter.string(from: NSNumber(value: stockInfo.price))!
            self.stockPrice.stringValue = commaString
        }
    }

    // MARK: - IBAction
    @IBAction func actionDelete(_ sender: NSButton) {
        self.stockInfo?.update(isWatchList: false)
    }

}
StockInfo.swift
import Cocoa
import RealmSwift

class StockInfo: Object {
    @objc dynamic var name = ""
    @objc dynamic var price = -1
    @objc dynamic var status = StockStatus.flat.rawValue
    @objc dynamic var isWatchList = true
    @objc dynamic var createdAt = Date()
    @objc dynamic var updatedAt = Date()
}

extension StockInfo {
    /// 状態をEnum形式で返却
    var stockStatus: StockStatus {
        get {
            return StockStatus(rawValue: self.status) ?? StockStatus.flat
        }
        set {
            self.status = newValue.rawValue
        }
    }

    /// watch list の表示/非表示を切り替える
    func update(isWatchList: Bool) {
        let realm = try! Realm()
        try! realm.write {
            self.isWatchList = isWatchList
            self.updatedAt = Date()
        }
    }

    /// StockInfo オブジェクトを作成
    class func create(realm: Realm, name: String, price: Int, status: StockStatus) -> StockInfo {
        let stockInfo = realm.create(StockInfo.self)
        stockInfo.name = name
        stockInfo.price = price
        stockInfo.stockStatus = status

        return stockInfo
    }

    /// 企業名で部分一致検索(空の場合は全件取得)
    class func objects(isWatchList: Bool? = true, searchValue: String = "") -> Results<StockInfo> {
        var result = try! Realm().objects(StockInfo.self)
        let searchValue = searchValue.trimmingCharacters(in: .whitespaces)
        if let isWatchList = isWatchList {
            result = result.filter("isWatchList == %@", isWatchList)
        }
        if searchValue != "" {
            result = result.filter("name CONTAINS %@", searchValue)
        }

        return result.sorted(byKeyPath: "createdAt", ascending: true)
    }
}

// MARK: - debug
extension StockInfo {

    /// データ初期化
    class func debugInitData() {
        let realm = try! Realm()
        try! realm.write {
            // データクリア
            realm.deleteAll()

            // サンプルデータ作成
            let _ = StockInfo.create(realm: realm, name: "○○株式会社", price: 1000, status: .up)
            let _ = StockInfo.create(realm: realm, name: "○×水産", price: 12345, status: .down)
            let _ = StockInfo.create(realm: realm, name: "株式会社□○", price: 345, status: .flat)
            let _ = StockInfo.create(realm: realm, name: "×△ホールディングス", price: 321, status: .up)
            let _ = StockInfo.create(realm: realm, name: "ABC BANK", price: 20, status: .down)
            let _ = StockInfo.create(realm: realm, name: "▼○重工", price: 98000, status: .up)

            // サンプル用に1,000 件データを作成
//            for i in 1...1_000 {
//                let status = StockStatus.init(rawValue: Int.random(in: 0..<3))!
//                let price = Int.random(in: -1..<10_000_000)
//                let _ = StockInfo.create(realm: realm, name: "○△×株式会社 \(i)", price: price, status: status)
//            }
        }
    }

    /// データ更新
    class func debugRandomUpdate(stockValues: Results<StockInfo>) {
        let realm = try! Realm()
        try! realm.write {
            for stockValue in stockValues {
                let status = StockStatus(rawValue: Int.random(in: 0..<3))!
                stockValue.stockStatus = status

                var value = Int(Double(stockValue.price) * 0.05)
                if value == 0 {
                    value = 1
                }
                switch status {
                case .down:
                    stockValue.price += -Int.random(in: 0..<value)
                    if stockValue.price < 0 {
                        stockValue.price = 1
                    }
                    stockValue.updatedAt = Date()
                case .up:
                    stockValue.price += Int.random(in: 0..<value)
                    stockValue.updatedAt = Date()
                case .flat:
                    break
                }
            }
        }
    }
}

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

CombineのPublisherをテストするためのexpect/expectErrorメソッドを作成した

おそらく現在はユニットテストでCombineのPublisherの値を検査するためにはXCTestExpectationを駆使したりしないといけません(簡便な方法があったら教えて欲しいです。)。さらに私はSwiftCheckを利用しているので検査結果をBool値で取得したいという要求があります。

ですのでテストターゲットでexpectとexpectErrorという検査メソッドをPublisherに追加しました。

import XCTest
import Combine

public extension Publisher where Output: Equatable {
    @discardableResult
    func expect(_ expectedValue: Output,
                takesNewest: Bool = false,
                timeout: TimeInterval = 2.0,
                fulfillmentCount: Int = 1,
                file: StaticString = #file,
                function: StaticString = #function,
                line: UInt = #line) -> Bool {
        var result                 = false
        var actualValues           = [Output]()
        var actualFulfillmentCount = 0
        var cancellables           = Set<AnyCancellable>()
        let exp                    = XCTestExpectation()
        exp.expectedFulfillmentCount = fulfillmentCount
        exp.assertForOverFulfill = true

        let waiter = XCTWaiter()

        self.receive(on: RunLoop.main).sink(receiveCompletion: { _ in }, receiveValue: {
            if takesNewest || !result {
                result = $0 == expectedValue
            }
            actualValues.append($0)
            actualFulfillmentCount += 1
            exp.fulfill()
        }).store(in: &cancellables)

        _ = waiter.wait(for: [exp], timeout: timeout)

        XCTAssertLessThanOrEqual(fulfillmentCount,
                                 actualFulfillmentCount,
                                 "\(file) - \(function):\(line): Expectation is short of fulfillment. '\(fulfillmentCount)' expected, but '\(actualFulfillmentCount)'.")

        XCTAssertTrue(result,
                      "\(file) - \(function):\(line): Expected output is '\(String(describing: expectedValue))', but actual stream is '\(String(describing: actualValues))'")

        return result && fulfillmentCount <= actualFulfillmentCount
    }
}

public extension Publisher where Failure: Equatable {
    @discardableResult
    func expectError(_ expectedError: Failure,
                     timeout: TimeInterval = 2.0,
                     file: StaticString = #file,
                     function: StaticString = #function,
                     line: UInt = #line) -> Bool {
        var actualError: Failure?
        var cancellables = Set<AnyCancellable>()
        let exp          = XCTestExpectation()

        let waiter = XCTWaiter()

        self.receive(on: RunLoop.main).sink(receiveCompletion: { completion in
            switch completion {
            case .failure(let receivedError):
                actualError = receivedError
            default: ()
            }

            exp.fulfill()
        }, receiveValue: { _ in }).store(in: &cancellables)

        _ = waiter.wait(for: [exp], timeout: timeout)

        XCTAssertEqual(expectedError, actualError,
                       "\(file) - \(function):\(line): Expected error is '\(String(describing: expectedError))', but actual error is '\(String(describing: actualError))'")

        return expectedError == actualError
    }
}

expectする値が取得できれば成功、できなければ失敗、そして結果のBool値を返すようになっています。
timeoutは値を取得する制限時間です。
expectのtakesNewestは、trueであればストリームの最新の値まで全て検査し、過去に該当する値があっても結果を上書きします。fulfillmentCountは何回値を取得するかを設定します。

テストで利用するには以下のように記述します。

class MyTests: XCTestCase {
    class P: Publisher {
        typealias Output = Int
        typealias Failure = Never

        func receive<S>(subscriber: S) where S : Subscriber, P.Failure == S.Failure, P.Output == S.Input {
            DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) {
                subscriber.receive(1)
                subscriber.receive(2)
                subscriber.receive(completion: .finished)
            }

            let subscription = Subscriptions.empty
            subscriber.receive(subscription: subscription)
        }
    }

    class Q: Publisher {
        typealias Output = Int
        typealias Failure = MyError

        func receive<S>(subscriber: S) where S : Subscriber, Q.Failure == S.Failure, Q.Output == S.Input {
            DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) {
                subscriber.receive(completion: .failure(.error1))
            }

            let subscription = Subscriptions.empty
            subscriber.receive(subscription: subscription)
        }
    }


    enum MyError: Error, Equatable {
        case error1
        case error2
    }


    func testExpect() {
        P().expect(1)
        P().buffer(size: 2, prefetch: .keepFull, whenFull: .dropOldest).collect().expect([1, 2])

        P().expect(1, takesNewest: true, fulfillmentCount: 2) // failed
        P().expect(3) // failed
    }

    func testExpectError() {
        Q().expectError(.error1)
    }
}

私のプロジェクトでは一応動作しているようです。
バグやより良い実装があれば教えてください。

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

UIKitのカスタムビュー側の値をSwiftUIのView側で取得する方法

はじめに

SwiftUIだけでは用意されていないコンポーネントがあったりでUIKit使いたいなーという状況はよくあるかと思います。

そんな時は、SwiftUIで頑張らないで、UIKitで作ってしまえ...と思い、カスタムビューを作成したのですが、カスタムビュー側で保持している値SwiftUIのView側で使いたいという状況に遭遇したので、

今回はその方法を備忘録兼ねて残します。

また、今回は、SwiftUIの方にないSearchBarをUIKitで作り、UISearchBarのテキストフィールドに入力された値をSwiftUI側で取得するというサンプルで試してみます。

環境

Xcode11 GM SEED
SwiftUI
Mac OS 10.14.6(18G103)

まずXibを使ったカスタムビューを作成しておく

以下のようにカスタムビューをUIViewで定義しておきます。
今まで通りAutoLayoutはしっかり設定させます。

アウトレットでついているのは、UISearchBarとカスタムビュー自体のContentViewだけ。
そしてXib側ではLabelを追加しています。
また、見やすいようにUIKitで作成したカスタムビューは青色にしておきます。

import UIKit

// Define UIKitSearchView in the usual way to create custom views.

class UIKitSearchView: UIView {
    @IBOutlet weak var contentView: UIView!
    @IBOutlet weak var searchBar: UISearchBar!

    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit()
    }

    private func commonInit() {
        Bundle.main.loadNibNamed("UIKitSearchView", owner: self, options: nil)
        contentView.frame = bounds
        addSubview(contentView)

        searchBar.placeholder = "UIKitSearchView's searchBar"
        searchBar.backgroundImage = UIImage()
    }
}

スクリーンショット 2019-10-01 20.51.39.png

これでカスタムビューの準備は終わりました。

UIViewRepresentableを準拠したSwiftUISearchViewを定義する

SwiftUIで宣言されたViewの中でUIViewを使う場合は、UIViewRepresentableプロトコルを使用したViewでラップすることが必要です。

このSwiftUISearchViewがSwiftUIで使う為のラップビューとなります。

import SwiftUI

struct SwiftUISearchView: UIViewRepresentable {

    func makeUIView(context: Context) -> UIKitSearchView {
        print("\(#function)")
        return UIKitSearchView()
    }

    func updateUIView(_ uiView: UIKitSearchView, context: Context) {
        print("\(#function)")
    }
}

シンプルにUIViewを表示させるだけであれば、このSwiftUISearchViewを宣言すれば終わりですが、このままではUISearchBarに入力された文字列を取得することができません。

UIViewRepresentableの定義を見てみる

UIKitSearchView側でクロージャを保持するプロパティを定義したり、強引にやればなんとかなりそうですが、スマートじゃないってことで
UIViewRepresentableのプロトコルの定義を調べてみます。

public protocol UIViewRepresentable : View where Self.Body == Never {

    /// The type of `UIView` to be presented.
    associatedtype UIViewType : UIView

    /// Creates a `UIView` instance to be presented.
    func makeUIView(context: Self.Context) -> Self.UIViewType

    /// Updates the presented `UIView` (and coordinator) to the latest
    /// configuration.
    func updateUIView(_ uiView: Self.UIViewType, context: Self.Context)

    /// Cleans up the presented `UIView` (and coordinator) in
    /// anticipation of their removal.
    static func dismantleUIView(_ uiView: Self.UIViewType, coordinator: Self.Coordinator)

    /// A type to coordinate with the `UIView`.
    associatedtype Coordinator = Void

    /// Creates a `Coordinator` instance to coordinate with the
    /// `UIView`.
    ///
    /// `Coordinator` can be accessed via `Context`.
    func makeCoordinator() -> Self.Coordinator

    typealias Context = UIViewRepresentableContext<Self>
}

@available(iOS 13.0, tvOS 13.0, watchOS 6.0, *)
@available(OSX, unavailable)
extension UIViewRepresentable where Self.Coordinator == Void {

    /// Creates a `Coordinator` instance to coordinate with the
    /// `UIView`.
    ///
    /// `Coordinator` can be accessed via `Context`.
    public func makeCoordinator() -> Self.Coordinator
}

@available(iOS 13.0, tvOS 13.0, watchOS 6.0, *)
@available(OSX, unavailable)
extension UIViewRepresentable {

    /// Cleans up the presented `UIView` (and coordinator) in
    /// anticipation of their removal.
    public static func dismantleUIView(_ uiView: Self.UIViewType, coordinator: Self.Coordinator)

    /// Declares the content and behavior of this view.
    public var body: Never { get }
}

ふむふむ。
なんだかUIViewRepresentableを継承すると、以下の実装が必要となるようです。

  • makeUIView(context:)
  • updateUIView(_:context:)
  • makeCoordinator()
  • dismantleUIView(_ uiView:coordinator:)

ただ、makeCoordinator()dismantleUIView(_ uiView:coordinator:)はextensionでデフォルト実装がされているので、定義不要みたいですね。

なんかmakeCoordinator()が使えそう

dismantleUIViewは調べたところ、表示されるUIKitビュー(およびコーディネーター)をクリーンアップするのに使うらしい。(よくわかりませんでした。)
どなたか教えていただけると嬉しいです。

ということでmakeCoordinatorがなんか使えそう。

ググってみると
なんでも、このCoordinatorを使用するとデリゲートやデータソース、ユーザーの操作に対するイベントハンドリングのようなUIKitなどで実装していた一般的な処理をSwiftUIでも実装できるらしい。

extension UIViewRepresentable where Self.Coordinator == Void {

    /// Creates a `Coordinator` instance to coordinate with the
    /// `UIView`.
    ///
    /// `Coordinator` can be accessed via `Context`.
    public func makeCoordinator() -> Self.Coordinator
}

UIViewRepresentableを継承したSelfにあるCoordinatorがなければ、デフォルト実装をするようになっているみたいなので、
つまりは、さきほど作ったSwiftUISearchViewの中にCoordinatorを作ればいいっぽい。

そして、このCoordinatorUISearchBarDelegateをつけちゃいます。

Coordinatorを作ったらmakeCoordinatorも追加して、さらにdelegateも指定させます。

struct SwiftUISearchView: UIViewRepresentable {

    func makeCoordinator() -> SwiftUISearchView.Coordinator {
        return Coordinator()
    }

    func makeUIView(context: Context) -> UIKitSearchView {
        print("\(#function)")
        let view = UIKitSearchView()
        view.searchBar.delegate = context.coordinator
        return view
    }

    func updateUIView(_ uiView: UIKitSearchView, context: Context) {
        print("\(#function)")
    }
}
extension SwiftUISearchView {
    class Coordinator: NSObject, UISearchBarDelegate {
        func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
            print("\(#function)")
        }

        func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
            print("\(#function)")
        }
    }
}

おー、これでCoodinatorで通知を受け取れるようになりました。

UISearchBarのテキストに入力した値を取得する

@EnvironmentObjectを使えば簡単ですが、それだと設計上良くなさそう。

なので、今回は、@State + @Bindingで実現させてみます。

おさらい

@Stateとは?

SwiftUIだとViewはstructなので、値の更新ができないけれども
@Stateを宣言すると、値を更新することができるようになるバインド用の修飾子。

@Bindingとは?

親Viewの@State attributeがついた変数などの値の更新通知を受け取れるようにするバインド修飾子のようです。(なんか使えそう...)
僕の中でこいつはまだふわっとしています。アドバイスお待ちしております

バインドさせるイメージ

1.親のContentViewでtextを保持

2.子のSwiftUISearchViewに親のContentViewで定義したtextを渡して同期させる

3.UISearchBarの変更通知を受けるのはCoordinatorなので、SwiftUISearchViewからCoordinatorへもtextを渡して同期させる

こんな感じで行けるんじゃないかと思っています。
それでは、残りをやっていきます。

親ビューを作ってtextをバインドさせる

まず親ViewであるContentView作ります。
そこで@StateでUISearchBarからの入力値を保持しておく受け皿を用意しておきます。

struct ContentView: View {
    // 親ビューで値が知りたいので、@Stateでtextを定義しておく
    @State private var text: String = DEFAULT_TEXT

    var body: some View {
        VStack(alignment: .center) {
            // バインドさせたいのでSwiftUISearchViewの初期化処理で突っ込む
            SwiftUISearchView(text: $text)
                .frame(height: 100.0)
            Spacer()
            VStack {
                Text("SwiftUI View")
                    .font(.title)
                    .padding(.bottom, 20)
                Text(text)
            }
            .multilineTextAlignment(.center)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(Color(.blue).opacity(0.5))
            .padding()
            Spacer()
        }
    }
}

SwiftUISearchViewとCoordinatorにもtextを保持するようにプロパティと処理を追加

親ビューに続いて、SwiftUISearchView、Coordinatorにもtextをバインドさせる為に、@Bindingでプロパティを追加し、さらに初期化やデリゲート通知を受けた時の処理を追加しておきます。

struct SwiftUISearchView: UIViewRepresentable {
    @Binding var text: String

    func makeCoordinator() -> SwiftUISearchView.Coordinator {
        return Coordinator(text: $text)
    }

    func makeUIView(context: Context) -> UIKitSearchView {
        print("\(#function)")
        let view = UIKitSearchView()
        view.searchBar.delegate = context.coordinator
        return view
    }

    func updateUIView(_ uiView: UIKitSearchView, context: Context) {
        print("\(#function)")
    }
}

extension SwiftUISearchView {
    class Coordinator: NSObject, UISearchBarDelegate {
        @Binding var text: String

        init(text: Binding<String>) {
            _text = text
        }

        func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
            print("\(#function)")
            text = searchText.isEmpty ? DEFAULT_TEXT: searchText
        }

        func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
            print("\(#function)")
            text = DEFAULT_TEXT
            searchBar.text = nil
        }
    }
}

ここまでで一旦完成しました。
あとは実行して確認します。

結果

実行してみると無事値がSwiftUI側の親ビュー側に同期されていました。
イメージ通りです。
これは@Binding便利です。

swiftui_sample_gif

github

https://github.com/kazy-dev/UISearchbar-from-SwiftUI-Sample

まとめ

今回は、UIKitのカスタムビュー側の値SwiftUIのView側で取得するという方法をやってみましたが、なんとかイメージ通りに機能したので、すっきりしました。

ただ、これが正しいのかが正直良くわかっていませんので
アドバイスやご指摘などあれば、ぜひぜひコメントお願いいたします。

SwiftUI関連は、これから色々最適解が出てくると思いますので、それまではトライアンドエラーでやってみたいと思います。

それでは、最後までお読みいただきありがとうございました。

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

【Swift】 ボタン押下時に画面にViewを追加する

はじめに

会社で提供してるWebサービス専用のタブブラウザを作れないか、というコトで、実現性評価を何段階かに分けてやってるうちの1つ。
ボタン押下時にUIViewを画面に追加する。

開発環境

端末:MacBook Pro/MacOS 10.14.5(Mojave)
Xcode:10.2.1
Swift:5

やったこと

ボタン押下時に、画面のランダムな場所に10px*10pxのUIViewを表示。
 →ボタンを押下するたびにどんどんUIViewを追加していく。

実装

画面イメージ
起動時
ボタン連打

※色と場所がランダムに設定されたUIViewがボタンを押すたびに画面に配置されていく。

ソースサンプル
ViewController.swift
import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var btnCreateView: UIButton!

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.

        btnCreateView.center = view.center
    }

    @IBAction func createView(_ sender: Any) {
        let testView: UIView = UIView()

        let viewWidth = UIScreen.main.bounds.width
        let viewHeight = UIScreen.main.bounds.height

        let randX = CGFloat(Int.random(in: 0 ... Int(viewWidth)))
        let randY = CGFloat(Int.random(in: 0 ... Int(viewHeight)))

        let randRed = CGFloat(Int.random(in: 0 ... 255))
        let randGreen = CGFloat(Int.random(in: 0 ... 255))
        let randBlue = CGFloat(Int.random(in: 0 ... 255))

        testView.backgroundColor = .init(red: randRed/255,
                                         green: randGreen/255,
                                         blue: randBlue/255,
                                         alpha: 1)

        testView.frame = CGRect.init(x: randX, y: randY, width: 10, height: 10)

        view.addSubview(testView)
    }
}

感想等

実現性評価というか、まぁこうなるだろうな〜ってイメージを順当に形にした感じです。

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

SwiftUIでList内のViewは複数の遷移先を持つことができない

iOSアプリを開発する上で、UIKitのUITableViewCellでタップしたUIViewによって遷移先を変えるというのは、よくあると思います。
同じことをSwiftUIの ListNavigationView で実現しようとしましたが、うまくいかなかったのですが、その際に試したことを備忘録として残しておきます。
環境はXcode11.0 (11A420a)、iOS13.0です。

NavigationLink を2つ指定した場合

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            List {
                Row(city: "Twentynine Palms", state: "California")
                Row(city: "Port Alsworth", state: "Alaska")
                Row(city: "Skagway", state: "Alaska")
            }
        }
    }
}

struct Row: View {
    var city: String
    var state: String
    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            NavigationLink(city, destination: Text(city))
            NavigationLink(state, destination: Text(state))
        }
    }
}

Pushの遷移を行う場合は NavigationLink 使用するので、素直に実装すると、このようになるかと思います。
これをビルドすると以下のような挙動になりました。

Oct-01-2019 23-29-42.gif

1回のタップで遷移を2回おこなっていて、意図した挙動になっていません。(また表示自体も disclosureIndicator が2つ表示されてしまっています)

Button を2つ配置して NavigationLink.init(destination:isActive:label:) を使用した場合

NavigationLink のインターフェイスを調べているとNavigationLink.init(destination:isActive:label:) というメソッドが定義されています。
ドキュメントには定義以上の情報がないですが、利用できそうです。

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            List {
                Row(city: "Twentynine Palms", state: "California")
                Row(city: "Port Alsworth", state: "Alaska")
                Row(city: "Skagway", state: "Alaska")
            }
        }
    }
}

struct Row: View {
    var city: String
    var state: String
    @State var buttonDidTap = (false, "")
    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Button(city) {
                self.buttonDidTap = (true, self.city)
            }
            Button(state) {
                self.buttonDidTap = (true, self.state)
            }
            NavigationLink(destination: Text(buttonDidTap.1), isActive: $buttonDidTap.0) {
                EmptyView()
            }
        }
    }
}

実装は上記のように各ボタンのアクションを NavigationLink にバインドしています。

Oct-02-2019 00-08-27.gif

一見上手く遷移できているようですが、どのボタンをタップしても2番目にあるボタンの遷移先になってしまっています。
各ボタンのアクションにブレイクポイントを貼って、もう少し挙動を確認してみます。

Oct-02-2019 00-16-05.gif

ボタンを1つだけタップしているはずですが、各ボタンのアクションが実行されてしまっています。
そのため最後のボタンイベントが優先されてしまい、このような挙動になってしまっているようです。

最後に

上記のように、素直な方法では実現できませんでした。
そのため、Listを用いた一覧画面は遷移を1つに絞り、詳細画面で複数の遷移先を持つデザインに変更して、解決することにしました。

もし他に良い方法を知っている方がいれば教えてほしいです。(UIKitを使えば色々な解決方法はありそうですよね)

参考資料

https://developer.apple.com/documentation/swiftui/navigationlink/3364630-init

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

【iOS】AESで暗号化・復号する方法(CryptoSwift)

背景

APIと連携するようなサーバー通信時には、平文ではなく暗号化したい。
そこで、CryptoSwiftを用いて簡単に暗号化・復号できるらしいので試してみました。

iOSアプリでAESでの暗号化のためCryptoSwiftを使用した
↓↓↓↓↓↓↓↓
自分の記事を作成中にCryptoSwiftの良記事を見つけてしまったので、メモレベルで記載します。笑

AESとは

暗号化のやり方の一つ
2018年現在のアメリカで標準として採用されているやり方
共通鍵暗号方式
鍵長が128ビット、192ビット、256ビットから選ぶことができる
WPA2で標準で採用されているやり方
WPAで使われる場合もある
引用:iOSアプリでAESでの暗号化のためCryptoSwiftを使用した

バージョン

  • Xcode 10.2.1
  • Swift 5.0.1
  • CryptoSwift 1.0.0

CryptoSwiftライブラリを導入する

CocoaPodsを用いてCryptoSwiftを導入します。Podfileは以下になります。

Podfile
target 'Qiita_AES' do
  use_frameworks!
  # CryptoSwiftライブラリを追加する
  pod 'CryptoSwift'
end

CocoaPodsの導入方法がわからない場合は、こちらの記事を参考にしてください。
【Swift】CocoaPods導入手順

暗号化・復号用のEncryptionAESクラスを実装

EncryptionAES.swift
import Foundation
import CryptoSwift

class EncryptionAES {

    // 暗号化処理
    // key:変換キー
    // iv:初期化ベクトル(時間などを使用してランダム生成するとよりセキュアになる)
    // text:文字列
    func encrypt(key: String, iv:String, text:String) -> String {

        do {
            // 暗号化処理
            // AES インスタンス化
            let aes = try AES(key: key, iv: iv)
            let encrypt = try aes.encrypt(Array(text.utf8))

            // Data 型変換
            let data = Data( encrypt )
            // base64 変換
            let base64Data = data.base64EncodedData()
            // UTF-8変換 nil 不可
            guard let base64String =
                String(data: base64Data as Data, encoding: String.Encoding.utf8)
                else { return "" }

            // base64文字列
            return base64String

        } catch {
            // エラー処理
            return ""
        }
    }

    // 複合処理
    // key:変換キー
    // iv:?(時間などを使用してランダム生成するとよりセキュアになる)
    // base64:文字列
    func decrypt(key: String, iv:String, base64:String) -> String {

        do {
            // AES インスタンス化
            let aes = try AES(key: key, iv:iv)

            // base64 から Data型へ
            let byteData = base64.data(using: String.Encoding.utf8)! as Data
            // base64 デーコード
            guard let data = Data(base64Encoded: byteData)
                else { return "" }

            // UInt8 配列の作成
            let aBuffer = Array<UInt8>(data)
            // AES 複合
            let decrypted = try aes.decrypt(aBuffer)
            // UTF-8変換
            guard let text = String(data: Data(decrypted), encoding: .utf8)
                else { return "" }

            return text
        } catch {
            // エラー処理
            return ""
        }
    }
}

呼び出し方法

let key = "abcdefghijklmnop" // 128bit(16文字)のキーを入れる
let iv =  "1234567890123456" // データをシフト演算するキー128bit(16文字)
let json = "{\"id\":2,\"name\":\"ほげほげ君\",\"hobby\":\"ボルダリング\"}" // 暗号化・復号するjson

// EncryptionAESのインスタンス化
let aes = EncryptionAES()

// jsonを暗号化
let jsonEncrypted = try! aes.encrypt(key: key, iv: iv, text: json)
print(jsonEncrypted)
// -> PlOEf5oeBt2GsyWDHUk7OtOUV6nRPpYQvqE919jkrrpWaAvQc+IhkrO3gPmiNQbkQOLLXMlnQh5U2wbwEPYksg==

// jsonを復号
let jsonDecrypted = aes.decrypt(key: key, iv: iv, base64: jsonEncrypted)
print(jsonDecrypted)
// -> {"id":2,"name":"ほげほげ君","hobby":"ボルダリング"}

まとめ

暗号化・復号と聞くと実装が大変なイメージでしたが、CryptoSwiftなら簡単にできるのでおすすめです。^^

以上になります。
もし不明点や間違い等があればコメントくださいm(_ _)m

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