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

いきなり!TDD(当方テスト初心者でいきなりTDD!) 3日目

はじめに

前回の、いきなり!TDD(当方テスト初心者でいきなりTDD!) 2日目では、

  • 仮実装
  • 三角測量

について学びました。今回は「明白な実装」について学びます。

開発環境

  • Xcode 10.3
  • Swift
  • XCTest

作るもの

ポーカー

明白な実装

必ずしも小さな歩幅でテストと開発を繰り返す必要はなく、確実性、自信のもてるコードがあると判断した場合には、仮実装をせずに、直接コードに落とし込む「明白な実装」を使い分けながら、TDDは進めていくのが通常です。

実装

現在のCardオブジェクトのnotationは、3♥のように記号が後にきている事に対し、現在の初期化は、

Card(suit:, rank:)

と、suit、rankの順になっているため、この順序が一致するように修正してみます。

Card.swift
struct Card {

    //順序を入れ替えた
    enum Rank: String {
        // (省略)
    }

    enum Suit: String {
        // (省略)
    }


    //順序を入れ替えた
    let rank: Rank
    let suit: Suit


    // (省略)
}

ここで、Command + U でテストを実行します。
結果はレッドになります。グリーンにするために、続けて、TDDPokerBySwiftTests.swiftも修正していきます。

TDDPokerBySwiftTests.swift
class TDDPokerBySwiftTests: XCTestCase {

    func testInitializeCard() {

        var card: Card

        card = Card(rank: .three, suit: .heart) //順序を入れ替えた
        XCTAssertEqual(card.suit, .heart)
        XCTAssertEqual(card.rank, .three)

        card = Card(rank: .jack, suit: .spade) //順序を入れ替えた
        XCTAssertEqual(card.suit, .spade)
        XCTAssertEqual(card.rank, .jack)
    }

    func testCardNotation() {
        var card: Card

        card = Card(rank: .three, suit: .heart) //順序を入れ替えた
        XCTAssertEqual(card.notation, "3♥")

        card = Card(rank: .jack, suit: .spade) //順序を入れ替えた
        XCTAssertEqual(card.notation, "J♠")
    }
}

Card.swiftの、

let rank
let suit

の変数定義を入れ替えたことで、初期化も、

Card(rank:, suit:)

の順に変更されました。
これは、swiftのstructは、プロパティの宣言順序に応じて、初期化の引数の順序が変わります。

この機能を考慮しない場合、

Card.swift
    let rank: Rank
    let suit: Suit

    init(rank: Rank, suit: Suit) {
        self.rank = rank
        self.suit = suit
    }

というように記述すべきですが、こういった機能を確実に理解しているのであれば、記述を省略することができますし、仮実装も省略して構いません。こういった考え方が「明白な実装」になります。

不要なテストを削除する(テストのリファクタリング)

notationは初期化時に渡したrankとすいtに応じて文字列を返却するプロパティになるため、Cardの初期化が成功し、同時にrankとすいtが期待通りインスタンスに保持されることが証明できています。
よって、既存のテストケースであるtestInitializeCard()は必要ないということになり、これを削除したテストコードが、以下になります。

TDDPokerBySwiftTests.swift
import XCTest
@testable import TDDPokerBySwift

class TDDPokerBySwiftTests: XCTestCase {

    func testCardNotation() {
        var card: Card

        card = Card(rank: .three, suit: .heart)
        XCTAssertEqual(card.notation, "3♥")

        card = Card(rank: .jack, suit: .spade)
        XCTAssertEqual(card.notation, "J♠")
    }
}

ToDoリストも更新しましょう。

  • Cardを定義して、インスタンスを作成する
    • CardはSuitを持つ
    • CardはRankを持つ
  • Cardのインスタンスから文字列表記(notation)を取得する

終わりに

今回は、

  • 明白な実装
  • テストのリファクタリング

について学びました。次回から、本格的なSwiftの設計、実装を踏まえながら、引き続きTDDを学びます。

出典

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

いきなり!TDD(当方テスト初心者でいきなりTDD!) 2日目

はじめに

前回は、TDDの概要と、簡単なinitをサンプルに、

レッド(テストを書く) > グリーン (プロダクトコードを書く) > リファクタリング(コードを整理する)

まで行いました。引き続き、TDDを行なっていきます。

開発環境

  • Xcode 10.3
  • Swift
  • XCTest

作るもの

ポーカー

現在のTODOリスト

[TODOリスト]

  • Cardを定義して、インスタンスを作成する
    • CardはSuitを持つ
    • CardはRankを持つ
  • Cardのインスタンスから文字列表記(notation)を取得する

それでは、最下段のTODOから再開していきましょう。

実装

まずは、レッドからです。TDDPokerBySwiftTests.swiftに以下を追加します。

TDDPokerBySwiftTests.swift
class TDDPokerBySwiftTests: XCTestCase {

   (省略)

   func testCardNotation() {
      let card = Card(suit: .heart, rank: .three)
      XCTAssertEqual(card.notation, "3♥")
   }
}

Command + U でテストを実行してみましょう。
スクリーンショット 2019-08-08 18.54.50.png

Cardにnotationプロパティが存在していないため、当然エラーになります。これはテストエラーではなく、単純にコンパイルエラーです。

それでは、早速、Cardにnotationプロパティを追加して、テストをグリーンにしていきましょう。

仮実装

Card.swiftに以下を追加します。

Card.swift
struct Card {

   (省略)

    var notation: String {
        return "3♥"
    }
}

Command + U でテストを実行してみましょう。
テストが成功すると思います。このnotationプロパティは、initで代入されるrankとsuitから生成されるものなので、外から代入された値を保持するStored Propertyではなく、Computed Propertyによる実装になっています。

Stored Property (保持型プロパティ)

今回で言う所の、Cardクラスのsuitとrankがそうですね。willSet、didSetなどで監視もでき、lazy修飾詞も使用可能です。

Card.swift
let suit: Suit
let rank: Rank

Computed Property (計算型プロパティ)

それ自体は値を保持せず、setter/getterで値を計算し(set)または返す(get)プロパティです。上記notationの定義がそれになるのですが、上記は、getのみのreadonly定義になっており、get{}が省略されています。省略しない場合には、以下のようになります。

Card.swift
var notation: String {
    get {
        return "3♥"
    }
}

setterを記述する場合には、

var notation: String {
    get {
        return "3♥"
    }

    set(t) {
        print("セット!\(t)");
    }
}

や、

var notation: String {
    get {
        return "3♥"
    }

    set {
        print("セット!\(newValue)");
    }
}

で、notationの値がセットされた時に、setが呼ばれます。set{}内では、Stored Propertyを含めた計算なども可能です。

具体的な使い方の例です。

class Person {
    var birthday: NSDate    // 生年月日
    var age: Int {          // 年齢
        get {
            let components = self.calendar.components(.CalendarUnitYear, fromDate: birthday, toDate: NSDate.date(), options: .allZeros)
            return components.year
        }
        set(newAge) {
            let diff = self.age - newAge
            if diff != 0 {
                self.birthday = self.calendar.dateByAddingUnit(.CalendarUnitYear, value: diff, toDate: self.birthday, options: .allZeros)
            }
        }
    }
    let dateFormatter: NSDateFormatter  // 日付フォーマッタ
    let calendar: NSCalendar            // カレンダー
    // イニシャライザ
    init(birthday: String) {
        self.dateFormatter = NSDateFormatter();
        self.dateFormatter.dateFormat = "Y/M/d"
        self.calendar = NSCalendar.currentCalendar()
        self.birthday = self.dateFormatter.dateFromString(birthday)!
    }
}

let p = Person(birthday: "1980/12/31")
print(p.age)
p.age = p.age - 2
print(p.birthday)     // 1982/12/31

三角測量

話を戻します。現在、notationは"3♥"を静的に返しているだけなので、一般化が保証されてません。テストを修正して、別のケースも追加します。

TDDPokerBySwiftTests.swift
class TDDPokerBySwiftTests: XCTestCase {

    (省略)

    func testCardNotation() {
        let card1 = Card(suit: .heart, rank: .three)
        XCTAssertEqual(card1.notation, "3♥")

        let card2 = Card(suit: .spade, rank: .jack)
        XCTAssertEqual(card2.notation, "J♠")
    }
}

このように2つ以上のテストケースを用意することで、機能の一般化を導き出すテクニックを「三角測量」と言います。テストがグリーンになるように、Card.swiftを書き換えます。enumをStringで定義し直し、各caseに文字列を代入します。取り出す際には、rawValueで取り出せます。

Card.swift
struct Card {

    enum Suit: String {
        case spade = "♠"
        case heart = "♥"
        case club = "♣"
        case diamond = "♦"
    }

    enum Rank: String {
        case ace    = "A"
        case two    = "2"
        case three  = "3"
        case four   = "4"
        case five   = "5"
        case six    = "6"
        case seven  = "7"
        case eight  = "8"
        case nine   = "9"
        case ten    = "10"
        case jack   = "J"
        case queen  = "Q"
        case king   = "K"
    }

    enum CardType {
        case other
    }

    let suit: Suit
    let rank: Rank

    var notation: String {
        return rank.rawValue + suit.rawValue
    }
}

Command + U でテストを実行しましょう。テストが成功すれば、グリーンのステップを終えたことになるので、リファクタリングを行います。今回も、cardの変数をテストケースごとに初期化して、共通利用できるようにするだけです。

TDDPokerBySwiftTests.swift
class TDDPokerBySwiftTests: XCTestCase {

    (省略)

    func testCardNotation() {
        var card: Card

        card = Card(suit: .heart, rank: .three)
        XCTAssertEqual(card.notation, "3♥")

        card = Card(suit: .spade, rank: .jack)
        XCTAssertEqual(card.notation, "J♠")
    }
}

まとめ

今回は、

  • 仮実装
  • 三角測量

について学びました。
次回は、「明白な実装」というものについて、学んで行きたいと思います。

参考

プロパティ
https://tea-leaves.jp/swift/content/%E3%83%97%E3%83%AD%E3%83%91%E3%83%86%E3%82%A3

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

いきなり!TDD(当方テスト初心者でいきなりTDD!) 1日目

TDDとは

Test-Driven Developmentのこと。ある機能に対して期待する結果を、プロダクトコードより先に”失敗するテスト”として書き(テストファースト)、実装のゴールを目指す開発スタイル。同時に、「テストしやすい」 == 「よい設計」と捉えるならば、よい設計手法とも言えます。「心理的安全を得たままにプロダクトコードの改善をすること」が目的の1つとなります。

■メリット

  • メンテナンスがしやすい
  • デバッグにかける時間を減らせる
  • 素早い設計判断、設計改善を行える

 
■デメリット

  • チームの成熟度やプロジェクトの状況によっては、アダとなることがある
  • 仕様変更が生じたと時は、テストのメンテナンスも発生する
  • ビルド時間がテストファーストの負荷を上げる

レッド/グリーン/リファクタリング

1、レッド:これから作るもののゴールを定める(テストを書く)
2、グリーン:期待する結果を満たす足場を作る(プロダクトコードを書く)
3、リファクタリング:作った羽柴を保ちながら改良を行う(コード整理、共通化、設計見直し)

開発環境

  • Xcode 10.3
  • Swift
  • XCTest

作るもの

ポーカー

まずは、実装したいことをTODOリストに書き起こす

[目的]
-スート(suit)とランク(rank)を与えて、カード(card)を生成する
-生成したカードから文字列表記(notation)を取得する

↓TODOリストに置き換える

[TODOリスト]

  • Cardを定義して、インスタンスを作成する
    • CardはSuitを持つ
    • CardはRankを持つ
  • Cardのインスタンスから文字列表記(notation)を取得する

プロジェクト作成

1.Xcode > Create a new Xcode Project > Single View App
2.プロジェクト名:TDDPokerBySwift
3.Include Unit Testsのチェックをオン
4.Next > ディレクトリ指定 > Create

初回テスト

Command + U でテスト実行

Build SuccessでOK

実際にテストを作成する

TDDPokerBySwiftTests.swiftを開いて、以下のように修正する

TDDPokerBySwiftTests.swift
import XCTest
@testable import TDDPokerBySwift

class TDDPokerBySwiftTests: XCTestCase {

    func testInitializeCard() {
        let card1 = Card(suit: .heart, rank: .three)
        XCTAssertEqual(card1.suit, .heart)
        XCTAssertEqual(card1.rank, .three)

        let card2 = Card(suit: .spade, rank: .jack)
        XCTAssertEqual(card2.suit, .spade)
        XCTAssertEqual(card2.rank, .jack)
    }
}

Command + U でテスト実行してみましょう。
当然、エラーになります。これは、テストコードのエラーでなはく、単純にCardクラスが存在しないためのコンパイルエラーです。
スクリーンショット 2019-08-08 16.03.40.png

Cardクラスを作成しましょう。今回もMVVMで作成していければと思いますので、
Project Navigator > TDDPokerBySwiftグループ > 右クリック > New Groupで、
- Models
- Views
- ViewControllers
を作成して、Models/Card.swiftを作成します。
スクリーンショット 2019-08-08 16.07.46.png

Card.swift
import Foundation

struct Card {

    enum Suit {
        case spade
        case heart
        case club
        case diamond
    }

    enum Rank {
        case ace
        case two
        case three
        case four
        case five
        case six
        case seven
        case eight
        case nine
        case ten
        case jack
        case queen
        case king
    }

    let suit: Suit
    let rank: Rank
}

それでは、Command + U でテスト実行してみましょう。
Build Succeeded > Test Succeeded となれば成功です。

ここまでで、レッド > グリーンまで作業しました。
仕上げに、リファクタリングです。

テストコードを以下に書き換えましょう。

TDDPokerBySwiftTests.swift
import XCTest
@testable import TDDPokerBySwift

class TDDPokerBySwiftTests: XCTestCase {

    func testInitializeCard() {

        var card: Card

        card = Card(suit: .heart, rank: .three)
        XCTAssertEqual(card.suit, .heart)
        XCTAssertEqual(card.rank, .three)

        card = Card(suit: .spade, rank: .jack)
        XCTAssertEqual(card.suit, .spade)
        XCTAssertEqual(card.rank, .jack)
    }
}

ローカル変数cardに統一したことで、直前に代入しているカードにアサーションをかけていると読めるようになりました。
それでは、Command + U でテスト実行してみましょう。

問題なければ、TODOリストにチェックを入れます。

  • Cardを定義して、インスタンスを作成する
    • CardはSuitを持つ
    • CardはRankを持つ
  • Cardのインスタンスから文字列表記(notation)を取得する

今回やったこと、理解したこと

  • TODOリストを書いて、取り組むべき問題を細分化した
  • テストの失敗か環境によるものかを切り分ける為に、テストを書き始める前にプロジェクトのテストを一度実行した
  • テストケースから考えることで、型をうまく利用し、考慮しなければならないテストケースを減らした
  • レッド > グリーン > リファクタリング まで一貫して行った

まとめ

今回やったことのようなシンプルなinitは、実際、テスト対象にはならないようなものだそうです。次節において、本格的なTDDが始まると思いますので、引き続き、頑張っていきましょう。

参考

演習問題
http://devtesting.jp/tddbc/?TDDBC%E4%BB%99%E5%8F%B007%2F%E8%AA%B2%E9%A1%8C

https://dev.classmethod.jp/event/twadatddworkshopsappororeport/

サンプルリポジトリ
https://github.com/ktanaka117/TDDPokerBySwift

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

[Swift]2以上の刻みで増加させるforのループ方法

はじめに

Objctive-CからSwiftへ書き換える上で、forの使い方が大きく変化しています。
インデックス情報が不要ならば、for-inの使いかたはそれほど変わっていないのでObjective-CやJavaなどの経験があればとくに困ることはないと思います。

しかし、インデックス情報が必要になる場合は、Swiftのお作法を覚える必要がでてきたので、備忘録として残しておきます。
なお環境は、Xcode10.2.1です。

1ずつ増加する

for (i, value) in 0..<10.enumerated() {
}

ちなみに、dropfirst()で作成された配列には、元のインデックス情報が残っているので、インデックス情報を合わせるためにzip()を使用する必要があります。

1以外の数値を増加する

Objective-Cなどの伝統的なforの記載で、1以外の値を増加させる方法は以下のようなやり方です。

// 伝統的なforの記述方法はSwiftでは使えない!
// 2づつ増加させる
for (int i=0; i < 10; i * 2) {
}

しかしSwiftではどうやってこれを実現するのでしょうか。
その方法は、stride()を使用します。ちなみにstrideの意味は「大股に歩く」だそうです。ナイスネーミング!

stride()で開区間( < )と閉区間( <= )はどうすればいいのでしょうか。
toではなくthroughに変更します。

含めない場合 ( < )

for i in stride(from: 0, to: 10, by: 2) {
    print("index=\(i)") // 0, 2, 4, 6, 8
}

含めたい場合 ( <=)

for i in stride(from: 0, through: 10, by: 2) {
    print("index=\(i)") // 0, 2, 4, 6, 8, 10
}

参照

Control Flow — The Swift Programming Language (Swift 5.1)

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

[iOS]よく使うSwiftライブラリ SnapKit編

#はじめに
ここまではRxSwift、Rx系のSwiftライブラリを見てきましたが、前回のRxKeyboardのプロジェクトで出てきた、SnapKitというのが、かなり有用だそうなので、こちらを取り上げていきたいと思います。

作るもの

スクリーンショット 2019-08-07 18.16.39.png

環境

Xcode10.3
Swift5.0.1
RxSwift 5.0.0
RxCocoa 5.0.0
SnapKit 5.0.0

準備

1.プロジェクト作成

-Xcodeを起動
-Create a new Xcode project
 >Single View App
 >Product Name:SnapKitSample
 >完了。すぐにプロジェクトを閉じます。

2.ターミナルを起動して、ディレクトリに移動

$ cd SnapKitSample

3.Podfile作成/編集

$ pod init
$ vi Podfile

今回はたくさんのライブラリを使います

Podfile
target 'SnapKitSample' do
  use_frameworks!

  pod 'RxSwift'
  pod 'RxCocoa'  
  pod 'SnapKit'

end

4.ライブラリのインストール

$ pod install

5.プロジェクトを開く
.xcworkspaceから起動する

Storyboardを削除

今回は、Storyboardもxibも使わずに、プログラムに直書きでレイアウトを表示します。

1.Main.storyboardの削除
/Main.storyboardをDelete > Move to Trash

2.Info.plist
Info.plistを開く > Main storyboard file base nameの項目を削除(マイナスボタン)

実装

AppDelegateを以下のように修正します。

AppDelegate.swift
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    //こうすることでSwift4.1以下でもdidFinishLaunchingWithOptionsが呼ばれるようにできる
    #if swift(>=4.2)
        typealias ApplicationLaunchOptionsKey = UIApplication.LaunchOptionsKey
    #else
        typealias ApplicationLaunchOptionsKey = UIApplicationLaunchOptionsKey
    #endif

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

        let window = UIWindow(frame: UIScreen.main.bounds)
        window.backgroundColor = .white
        window.makeKeyAndVisible()

        let viewController = ViewController()
        let navigationController = UINavigationController(rootViewController: viewController)
        window.rootViewController = navigationController

        self.window = window
        return true
    }
}
ViewController.swift
import UIKit
import SnapKit

class ViewController: UIViewController {

    var didSetupConstraints: Bool = false
    let container: UIView = UIView()
    let labelFullWidth: UILabel = UILabel()
    let labelHalfWidth: UILabel = UILabel()
    let labelQuarter: UILabel = UILabel()
    let label80Percent: UILabel = UILabel()
    let label20Percent: UILabel = UILabel()
    let label40Percent: UILabel = UILabel()
    let label10Percent: UILabel = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()
        self.setupViews()
    }

    fileprivate func setupViews() {

        //エイリアス定義
        typealias Content = (title: String, label: UILabel, color: UIColor)

        let font: UIFont? = UIFont(name: "Menlo-Bold", size: 15)

        let green: UIColor = UIColor(hue: 0.40, saturation: 0.77, brightness: 0.80, alpha: 1.0)
        let yellow: UIColor = UIColor(hue: 0.13, saturation: 0.99, brightness: 0.80, alpha: 1.0)
        let blue: UIColor = UIColor(hue: 0.57, saturation: 0.76, brightness: 0.86, alpha: 1.0)
        let purple: UIColor = UIColor(hue: 0.70, saturation: 0.52, brightness: 0.77, alpha: 1.0)
        let red: UIColor = UIColor(hue: 0.02, saturation: 0.74, brightness: 0.91, alpha: 1.0)
        let pink: UIColor = UIColor(hue: 0.90, saturation: 0.49, brightness: 0.96, alpha: 1.0)
        let navy: UIColor = UIColor(hue: 0.12, saturation: 0.25, brightness: 0.94, alpha: 1.0)

        let contents: [Content] = [
            ("100%", labelFullWidth, green),
            ("50%", labelHalfWidth, yellow),
            ("1/4", labelQuarter, blue),
            ("80%", label80Percent, purple),
            ("20%", label20Percent, red),
            ("40%", label40Percent, pink),
            ("10%", label10Percent, navy),
        ]

        //すべてのラベルの共通設定
        for content in contents {
            let label = content.label
            label.font = font
            label.textAlignment = .center
            label.backgroundColor = content.color
            label.textColor = .black
            label.layer.opacity = 0.9
            label.text = content.title

            container.addSubview(label)
        }

        view.addSubview(container)
        view.backgroundColor = .white
        view.setNeedsUpdateConstraints()
    }

    override func updateViewConstraints() {

        if !didSetupConstraints {

            container.snp.makeConstraints{ make in

                //inset:内側余白 > padding
                make.edges.equalToSuperview().inset(
                    UIEdgeInsets(
                        top:view.safeAreaInsets.top,
                        left: 30,
                        bottom: view.safeAreaInsets.bottom,
                        right: 30)
                )

                //親ビューの中心と同じ位置に
                make.center.equalToSuperview()
            }

            labelFullWidth.snp.makeConstraints { make in
                make.width.equalToSuperview()
                make.height.equalTo(100)

                //オブジェクトの上辺をメインビューの中心Yに
                make.top.equalTo(view.snp.centerY)

                //widthが親ビューと一緒なので、左端から右端まで100%になる
                make.left.equalToSuperview()
            }

            labelHalfWidth.snp.makeConstraints { make in
                make.width.equalToSuperview().multipliedBy(0.5) //倍率表示
                make.height.equalTo(100)
                make.bottom.equalTo(view.snp.centerY)
                make.left.equalToSuperview()
            }

            labelQuarter.snp.makeConstraints { make in
                make.width.equalToSuperview().dividedBy(2)  //横2分割と
                make.height.equalToSuperview().dividedBy(2) //縦2分割の大きさで1/4の面積になる
                make.bottom.equalTo(view.snp.centerY)
                make.left.equalTo(view.snp.centerX)
            }

            label80Percent.snp.makeConstraints { make in
                make.width.equalToSuperview().multipliedBy(0.8)
                make.top.equalTo(labelFullWidth.snp.bottom)
                make.bottom.equalToSuperview()
                make.right.equalToSuperview()
            }

            label20Percent.snp.makeConstraints { make in
                make.width.equalToSuperview().multipliedBy(0.2)
                make.height.equalTo(label80Percent)
                make.top.equalTo(labelFullWidth.snp.bottom)
                make.left.equalToSuperview()
            }

            label40Percent.snp.makeConstraints { make in
                make.width.equalToSuperview().multipliedBy(0.4)
                make.top.equalToSuperview()
                make.left.equalToSuperview()
                make.bottom.equalTo(labelHalfWidth.snp.top)
            }

            label10Percent.snp.makeConstraints { make in
                make.width.equalToSuperview().multipliedBy(0.1)
                make.top.equalToSuperview()
                make.left.equalTo(label40Percent.snp.right)
                make.bottom.equalTo(labelHalfWidth.snp.top)
            }

            didSetupConstraints = true
        }

        super.updateViewConstraints()
    }
}

参考

https://tech.ryukyu-i.co.jp/2016/12/02/easy-to-use-snapkit/
https://github.com/tdkn/EasyToUseSnapKit/blob/master/EasyToUseSnapKit/Sample02ViewController.swift

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

[iOS]いきなり!RxSwift (当方はSwift初心者でいきなりRxSwift!) よく使うRxSwift系ライブラリ RxKeyboard編

はじめに

前回に引き続き、よく使うRxSwift系ライブラリとして、RxKeyboardをみていきたいと思います。

環境

Xcode10.3
Swift5.0.1
RxSwift 5.0.0
RxCocoa 5.0.0
RxKeyboard 1.0.0
SwiftyImage 1.1
SwiftyColor 1.2.0
CGFloatLiteral 0.5.0
ManualLayout 1.3.0
SnapKit 5.0.0
Then 2.5.0
ReusableKit 3.0.0
UITextView+Placeholder 1.2.1

つくるもの

iOSのMessageアプリのようなメッセージ形式のタイムライン&入力フォームのアプリケーションになります。

625eea7a-ddbe-11e6-9984-529abae1bd1a.gif14bd915c-8eb0-11e6-93ea-7618fc9c5d81.gif

準備

1.プロジェクト作成

-Xcodeを起動
-Create a new Xcode project
 >Single View App
 >Product Name:RxKeyboardSample
 >完了。すぐにプロジェクトを閉じます。

2.ターミナルを起動して、ディレクトリに移動

$ cd RxKeyboardSample

3.Podfile作成/編集

$ pod init
$ vi Podfile

今回はたくさんのライブラリを使います

Podfile
target 'RxKeyboardSample' do
  use_frameworks!

  pod 'RxSwift'
  pod 'RxCocoa'  
  pod 'RxKeyboard'
  pod 'ReusableKit'
  pod 'SwiftyImage'
  pod 'SwiftyColor'
  pod 'CGFloatLiteral'
  pod 'ManualLayout'
  pod 'SnapKit'
  pod 'Then'
  pod 'UITextView+Placeholder'

end

4.ライブラリのインストール

$ pod install

5.プロジェクトを開く
.xcworkspaceから起動する

Storyboardを削除

今回は、Storyboardもxibも使わずに、プログラムに直書きでレイアウトを表示します。

1.Main.storyboardの削除
/Main.storyboardをDelete > Move to Trash

2.Info.plist
Info.plistを開く > Main storyboard file base nameの項目を削除(マイナスボタン)

3.AppDelegateの修正

AppDelegate.swift
import UIKit

import CGFloatLiteral
import ManualLayout
import SnapKit
import SwiftyColor
import Then
import UITextView_Placeholder

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    //こうすることでSwift4.1以下でもdidFinishLaunchingWithOptionsが呼ばれるようにできる
    #if swift(>=4.2)
        typealias ApplicationLaunchOptionsKey = UIApplication.LaunchOptionsKey
    #else
        typealias ApplicationLaunchOptionsKey = UIApplicationLaunchOptionsKey
    #endif

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

        let window = UIWindow(frame: UIScreen.main.bounds)
        window.backgroundColor = .white
        window.makeKeyAndVisible()

        let viewController = MessageListViewController()
        let navigationController = UINavigationController(rootViewController: viewController)
        window.rootViewController = navigationController

        self.window = window
        return true
    }
}

MVVM用のグループを作成

Project Navigator - プロジェクトフォルダ上で右クリック - New Group
より、Models、Views、ViewControllersの3つのグループを作成し、

/ViewControllers
・ViewController.swiftをViewControllersフォルダに移動
・ファイル名を、MessageListViewController.swiftに変更
・クラス名を、MessageListViewControllerに変更

/Views
・MessageCell.swift
・MessageInputBar.swift
を作成

/Models
・User.swift
・Message.swift
を作成

スクリーンショット 2019-08-07 15.22.49.png

Model

User、MessageのModel編集します。

User.swift
enum User {
    case other
    case me
}
Message.sweft
struct Message {
    var user: User
    var text: String
}

View

MessageCell.swift
import Foundation

import UIKit

import SwiftyImage

final class MessageCell: UICollectionViewCell {

    // MARK: Types

    fileprivate enum BalloonAlignment {
        case left
        case right
    }

    // MARK: Constants

    struct Metric {
        static let maximumBalloonWidth = 240.f
        static let balloonViewInset = 10.f
    }

    struct Font {
        static let label = UIFont.systemFont(ofSize: 14.f)
    }

    // MARK: Properties

    fileprivate var balloonAlignment: BalloonAlignment = .left

    // MARK: UI

    //SwiftyImageによる拡張でオブジェクト作成
    fileprivate let otherBalloonViewImage = UIImage.resizable()
        .corner(radius: 5)
        .color(0xD9D9D9.color)
        .image

    //SwiftyImageによる拡張でオブジェクト作成
    fileprivate let myBalloonViewImage = UIImage.resizable()
        .corner(radius: 5)
        .color(0x1680FA.color)
        .image

    let balloonView = UIImageView()
    let label = UILabel().then {
        $0.font = Font.label
        $0.numberOfLines = 0
    }

    // MARK: Initializing

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.contentView.addSubview(self.balloonView)
        self.contentView.addSubview(self.label)
    }

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

    // MARK: Configuring

    func configure(message: Message) {
        self.label.text = message.text

        switch message.user {
        case .other:
            self.balloonAlignment = .left
            self.balloonView.image = self.otherBalloonViewImage
            self.label.textColor = .black

        case .me:
            self.balloonAlignment = .right
            self.balloonView.image = self.myBalloonViewImage
            self.label.textColor = .white

        }

        self.setNeedsLayout()   //再読み込み
    }

    // MARK: Size
    class func size(thatFitsWidth width: CGFloat, forMessage message: Message) -> CGSize {
        let labelWIdth = Metric.maximumBalloonWidth - Metric.balloonViewInset * 2
        let constraintSize = CGSize(width: labelWIdth, height: CGFloat.greatestFiniteMagnitude)
        let options: NSStringDrawingOptions = [.usesLineFragmentOrigin, .usesFontLeading]
        let rect = message.text.boundingRect(with: constraintSize,
                                             options: options,
                                             attributes: [.font: Font.label],
                                             context: nil)
        let labelHeight = ceil(rect.height)
        return CGSize(width: width, height: labelHeight + Metric.balloonViewInset * 2)
    }

    // MARK: Layout

    override func layoutSubviews() {
        super.layoutSubviews()

        //ManualLayout によるプロパティ操作
        self.label.width = Metric.maximumBalloonWidth - Metric.balloonViewInset * 2
        self.label.sizeToFit()

        self.balloonView.width = self.label.width + Metric.balloonViewInset * 2
        self.balloonView.height = self.label.height + Metric.balloonViewInset * 2

        switch self.balloonAlignment {
        case .left:
            self.balloonView.left = 10

        case .right:
            self.balloonView.right = self.contentView.width - 10
        }

        self.label.top = self.balloonView.top + Metric.balloonViewInset
        self.label.left = self.balloonView.left + Metric.balloonViewInset
    }

}

MessageInputBar.swift
import Foundation

import UIKit

import RxCocoa
import RxSwift
import RxKeyboard

final class MessageInputBar: UIView {

    // MARK: Properties

    private let disposeBag = DisposeBag()

    // MARK: UI

    let toolbar = UIToolbar()
    let textView = UITextView().then {
        $0.placeholder = "Say Hi!"
        $0.isEditable = true
        $0.showsVerticalScrollIndicator = false
        $0.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.6).cgColor
        $0.layer.borderWidth = 1 / UIScreen.main.scale
        $0.layer.cornerRadius = 3
    }

    let sendButton = UIButton(type: .system).then {
        $0.titleLabel?.font = UIFont.boldSystemFont(ofSize: UIFont.systemFontSize)
        $0.setTitle("Send", for: .normal)
    }

    // MARK: Initializing

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.addSubview(self.toolbar)
        self.addSubview(self.textView)
        self.addSubview(self.sendButton)

        //snp:SnapKit は AutoLayout の DSL
        self.toolbar.snp.makeConstraints { make in
            make.edges.equalTo(0)
        }

        self.textView.snp.makeConstraints { make in
            make.top.left.equalTo(7)
            make.right.equalTo(self.sendButton.snp.left).offset(-7)
            make.bottom.equalTo(-7)
        }

        self.sendButton.snp.makeConstraints { make in
            make.top.equalTo(7)
            make.bottom.equalTo(-7)
            make.right.equalTo(-7)
        }


        self.textView.rx.text
            .map { text in
                //1行記述はreturnを省略できる
                text?.isEmpty == false  //textViewのtextを監視し、textが空でなければ
            }
            .bind(to: self.sendButton.rx.isEnabled) //ボタンを押せるように
            .disposed(by: self.disposeBag)

        RxKeyboard.instance.visibleHeight
            .map{ $0 > 0}
            .distinctUntilChanged().drive(onNext: { [weak self] (visible) in
                guard let self = self else {
                    return
                }

                var bottomInset = 0.f
                if #available(iOS 11.0, *), !visible, let bottom = self.superview?.safeAreaInsets.bottom {
                    bottomInset =  bottom
                }

                self.toolbar.snp.remakeConstraints({ (make) in
                    make.left.right.top.equalTo(0)
                    make.bottom.equalTo(bottomInset)
                })
            })
            .disposed(by: disposeBag)

    }

    @available(iOS 11.0, *)
    override func safeAreaInsetsDidChange() {
        super.safeAreaInsetsDidChange()
        guard let bottomInset = self.superview?.safeAreaInsets.bottom else {
            return
        }

        self.toolbar.snp.remakeConstraints { make in
            make.top.left.right.equalTo(0)
            make.bottom.equalTo(bottomInset)
        }
    }

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

    // MARK: Size

    //自身のサイズを返す
    override var intrinsicContentSize: CGSize {
        return CGSize(width: self.width, height: 44)
    }
}

// MARK: - Reactive

//Rasctive
extension Reactive where Base: MessageInputBar {   //base変数の型がMessageInputBarになる

    var sendButtonTap: ControlEvent<String> {
        let source: Observable<String> = self.base.sendButton.rx.tap.withLatestFrom(self.base.textView.rx.text.asObservable())
            .flatMap { text -> Observable<String> in
                if let text = text, !text.isEmpty {
                    return .just(text)
                } else {
                    return .empty()
                }
            }
            .do(onNext: { [weak base = self.base] _ in
                base?.textView.text = nil
            })
        return ControlEvent(events: source)
    }
}

ViewController

MessageListViewController.swift
import UIKit

import ReusableKit
import RxKeyboard
import RxSwift

class MessageListViewController: UIViewController {

    // MARK: Constants

    struct Reusable {
        static let messageCell = ReusableCell<MessageCell>()    //ReusableCell:ReusableKitのクラス
    }

    // MARK: Properties

    private var didSetupViewConstaraints = false
    private var disposeBag = DisposeBag()

    fileprivate var messages: [Message] = [
        Message(user: .other, text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit."),
        Message(user: .other, text: "Morbi et eros elementum, semper massa eu, pellentesque sapien."),
        Message(user: .me, text: "Aenean sollicitudin justo scelerisque tincidunt venenatis."),
        Message(user: .me, text: "Ut mollis magna nec interdum pellentesque."),
        Message(user: .me, text: "Aliquam semper nibh nec quam dapibus, a congue odio consequat."),
        Message(user: .other, text: "Nullam iaculis nisi in justo feugiat, at pharetra nulla dignissim."),
        Message(user: .me, text: "Fusce at nulla luctus, posuere mauris ut, viverra nunc."),
        Message(user: .other, text: "Nam feugiat urna non tortor ornare viverra."),
        Message(user: .other, text: "Donec vitae metus maximus, efficitur urna ac, blandit erat."),
        Message(user: .other, text: "Pellentesque luctus eros ac nisi ullamcorper pharetra nec vel felis."),
        Message(user: .me, text: "Duis vulputate magna quis urna porttitor, tempor malesuada metus volutpat."),
        Message(user: .me, text: "Duis aliquam urna quis metus tristique eleifend."),
        Message(user: .other, text: "Cras quis orci quis nisi vulputate mollis ut vitae magna."),
        Message(user: .other, text: "Fusce eu urna eu ipsum laoreet lobortis."),
        Message(user: .other, text: "Proin vitae tellus nec odio consequat varius ac non orci."),
        Message(user: .me, text: "Maecenas gravida arcu ut consectetur tincidunt."),
        Message(user: .me, text: "Quisque accumsan nisl ut ipsum rutrum, nec rutrum magna lobortis."),
        Message(user: .other, text: "Integer ac sem eu velit tincidunt hendrerit a in dui."),
        Message(user: .other, text: "Duis posuere arcu convallis tincidunt faucibus."),
    ]

    // MARK: UI

    let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()).then {
        $0.alwaysBounceVertical = true
        $0.keyboardDismissMode = .interactive
        $0.backgroundColor = .clear
        $0.register(Reusable.messageCell)
        ($0.collectionViewLayout as? UICollectionViewFlowLayout)?.do({
            $0.minimumLineSpacing = 6
            $0.sectionInset.top = 10
            $0.sectionInset.bottom = 10
        })
    }

    let messageInputBar = MessageInputBar()

    // MARK: Initializing

    init() {
        super.init(nibName: nil, bundle: nil)
        self.title = "RxKeyboard Example"
    }

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

    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.addSubview(self.collectionView)
        self.view.addSubview(self.messageInputBar)

        self.collectionView.dataSource = self
        self.collectionView.delegate = self

        DispatchQueue.main.async {
            let indexPath = IndexPath(item: self.messageInputBar.accessibilityElementCount() - 1, section: 0)
            self.collectionView.scrollToItem(at: indexPath, at: [], animated: true)
        }

        RxKeyboard.instance.visibleHeight
            .drive(onNext: { [weak self] keyboardVisibleHeight in
                guard let self = self, self.didSetupViewConstaraints else { return }
                self.messageInputBar.snp.updateConstraints { make in
                    if #available(iOS 11.0, *) {
                        make.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).offset(-keyboardVisibleHeight)
                    } else {
                        make.bottom.equalTo(self.bottomLayoutGuide.snp.top).offset(-keyboardVisibleHeight)
                    }
                }
                self.view.setNeedsLayout()
                UIView.animate(withDuration: 0) {
                    self.collectionView.contentInset.bottom = keyboardVisibleHeight + self.messageInputBar.height
                    self.collectionView.scrollIndicatorInsets.bottom = self.collectionView.contentInset.bottom
                    self.view.layoutIfNeeded()
                }
            })
            .disposed(by: self.disposeBag)

        RxKeyboard.instance.willShowVisibleHeight
            .drive(onNext: { keyboardVisibleHeight in
                self.collectionView.contentOffset.y += keyboardVisibleHeight
            })
            .disposed(by: self.disposeBag)

        self.messageInputBar.rx.sendButtonTap
            .subscribe(onNext: { [weak self] text in
                guard let self = self else { return }
                let message = Message(user: .me, text: text)
                self.messages.append(message)
                let indexPath = IndexPath(item: self.messages.count - 1, section: 0)
                self.collectionView.insertItems(at: [indexPath])
                self.collectionView.scrollToItem(at: indexPath, at: [], animated: true)
            })
            .disposed(by: disposeBag)
    }

    override func updateViewConstraints() {
        super.updateViewConstraints()
        guard !self.didSetupViewConstaraints else { return }
        self.didSetupViewConstaraints = true

        self.collectionView.snp.makeConstraints { make in
            make.edges.equalTo(0)
        }
        self.messageInputBar.snp.makeConstraints { make in
            make.left.right.equalTo(0)
            if #available(iOS 11.0, *) {
                make.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom)
            } else {
                make.bottom.equalTo(self.bottomLayoutGuide.snp.top)
            }
        }
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        if self.collectionView.contentInset.bottom == 0 {
            self.collectionView.contentInset.bottom = self.messageInputBar.height
            self.collectionView.scrollIndicatorInsets.bottom = self.collectionView.contentInset.bottom
        }
    }
}

// MARK: - UICOllectionViewDataSource

extension MessageListViewController: UICollectionViewDataSource {

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return self.messages.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeue(Reusable.messageCell, for: indexPath)
        cell.configure(message: self.messages[indexPath.item])
        return cell
    }
}

// MARK: - UICollectionViewDelegateFlowLayout

extension MessageListViewController: UICollectionViewDelegateFlowLayout {

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        let message = self.messages[indexPath.item]
        return MessageCell.size(thatFitsWidth: collectionView.width, forMessage: message)
    }
}


参考

https://github.com/RxSwiftCommunity/RxKeyboard

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

[iOS]いきなり!RxSwift (当方はSwift初心者でいきなりRxSwift!) よく使うRxSwiftライブラリ RxDataSources編

はじめに

前回までは、カウンターアプリ、WebViewアプリなどのサンプルアプリを実装してきました。今回は、よく使うRxSwift系ライブラリのひとつとしてRxDataSourcesを、実装しながら見ていきたいと思います。

環境

Xcode10.3
Swift5.0.1
RxSwift 5.0.0
RxCocoa 5.0.0

<今回初めて使うライブラリ>
RxDataSources 4.0.1

準備

1.プロジェクト作成

-Xcodeを起動
-Create a new Xcode project
 >Single View App
 >Product Name:RxSwiftLibSample
 >完了。すぐにプロジェクトを閉じます。

2.ターミナルを起動して、ディレクトリに移動

$ cd RxDataSourcesSample

3.Podfile作成/編集

$ pod init
$ vi Podfile
Podfile
# platform :ios, '9.0'

target 'RxDataSourcesSample' do
  use_frameworks!

  pod 'RxSwift'
  pod 'RxCocoa' 
  pod 'RxDataSources'
end

4.ライブラリのインストール

$ pod install

5.プロジェクトを開く
.xcworkspaceから起動する

Storyboardを削除

1.Main.storyboardの削除
/Main.storyboardをDelete > Move to Trash

2.Info.plist
Info.plistを開く > Main storyboard file base nameの項目を削除(マイナスボタン)

3.AppDelegateの修正

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

    /* 追加 ここから */
    self.window = UIWindow(frame: UIScreen.main.bounds)
    let navigationController = UINavigationController(rootViewController: ViewController())
    self.window?.rootViewController = navigationController
    self.window?.makeKeyAndVisible()
    /* 追加 ここまで */

    return true
}

4.ViewController.xibの作成
- New File > View > Save As: ViewController.xib > Create
- ViewController.xibを開く
- Placeholders > File's Ownerを選択
- ClassにViewControllerを指定
- OutletのviewとXibのViewを接続
- Build & Run > 成功でOK

実装

SettingsViewModel.swift
import Foundation
import RxSwift
import RxCocoa
import RxDataSources

class SettingsViewModel {

    let items = BehaviorRelay<[SettingsSectionModel]> (value: [])

    var itemsObservable: Observable<[SettingsSectionModel]> {
        return items.asObservable()
    }

    func setup() {
        updateItems()
    }

    func updateItems() {
        let sections: [SettingsSectionModel] = [
            accountSection(),
            commonSection()
        ]
        items.accept(sections)
    }

    private func accountSection() -> SettingsSectionModel {
        let items: [SettingsItem] = [
            .account,
            .security,
            .notification,
            .contents
        ]
        return SettingsSectionModel(model: .account, items: items)
    }

    private func commonSection() ->  SettingsSectionModel {
        let items: [SettingsItem] = [
            .sounds,
            .dataUsing,
            .accessibility,
            .description(text: "基本設定はこの端末でログインしている全てのアカウントに適用されます。")
        ]
        return SettingsSectionModel(model: .common, items: items)
    }
}
SettingsSectionModel.swift
import Foundation
import UIKit
import RxDataSources

typealias SettingsSectionModel = SectionModel<SettingsSection, SettingsItem>

enum SettingsSection {
    case account
    case common
    case other

    var headerHeight: CGFloat {
        return 40.0
    }

    var footerHeight: CGFloat {
        return 1.0
    }
}

enum SettingsItem {
    //account section
    case account
    case security
    case notification
    case contents

    //common section
    case sounds
    case dataUsing
    case accessibility

    //other
    case description(text: String)

    var title: String? {
        switch self {
        case .account:
            return "アカウント"
        case .security:
            return "セキュリティ"
        case .notification:
            return "通知"
        case .contents:
            return "コンテンツ設定"
        case .sounds:
            return "サウンド設定"
        case .dataUsing:
            return "データ利用時の設定"
        case .accessibility:
            return "アクセシビリティ"
        case .description:
            return nil
        }
    }

    var rowHeight: CGFloat {
        switch self {
        case .description:
            return 72.0
        default:
            return 48.0
        }
    }

    var accessoryType: UITableViewCell.AccessoryType {
        switch self {
        case .account, .security, .notification, .contents, .sounds, .dataUsing, .accessibility:
            return .disclosureIndicator
        case .description:
            return .none
        }
    }
}

ViewController.swift
import UIKit
import RxSwift
import RxDataSources

class ViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!

    private var disposeBag = DisposeBag()

    private lazy var dataSource = RxTableViewSectionedReloadDataSource<SettingsSectionModel>(configureCell: configureCell)

    private lazy var configureCell: RxTableViewSectionedReloadDataSource<SettingsSectionModel>.ConfigureCell =
    {   [weak self] (dataSource, tableView, indexPath, _) in
        let item = dataSource[indexPath]

        switch item {
        case .account, .security, .notification, .contents, .sounds, .dataUsing, .accessibility:
            let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
            cell.textLabel?.text = item.title
            cell.accessoryType = item.accessoryType
            return cell


        case .description(let text):
            let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
            cell.textLabel?.text = text
            cell.isUserInteractionEnabled = false
            return cell
        }
    }

    private var viewModel: SettingsViewModel!

    override func viewDidLoad() {
        super.viewDidLoad()
        setupViewController()
        setupTableView()
        setupViewModel()
    }

    private func setupViewController()
    {
        navigationItem.title = "設定"
    }

    private func setupTableView()
    {
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        tableView.contentInset.bottom = 12.0
        tableView.backgroundColor = UIColor(red: 0.9, green: 0.9, blue: 0.9, alpha: 1.0)
        tableView.rx.setDelegate(self).disposed(by: disposeBag)
        tableView.rx.itemSelected
            .subscribe(onNext: { [weak self] indexPath in
                guard let item = self?.dataSource[indexPath] else { return }
                self?.tableView.deselectRow(at: indexPath, animated: true)
                switch item {
                case .account:
                    //遷移させる処理
                    //コンパイルエラー回避のためにbreakを書いていますが処理を書いていればbreakは入りません
                    break
                case .security:
                    //遷移させる処理
                    break
                case .notification:
                    //遷移させる処理
                    break
                case .contents:
                    //遷移させる処理
                    break
                case .sounds:
                    //遷移させる処理
                    break
                case .dataUsing:
                    //遷移させる処理
                    break
                case .accessibility:
                    //遷移させる処理
                    break
                case .description:
                    break
                }
            })
            .disposed(by: disposeBag)
    }

    private func setupViewModel()
    {
        viewModel = SettingsViewModel()

        viewModel.items
            .bind(to: tableView.rx.items(dataSource: dataSource))
            .disposed(by: disposeBag)

        viewModel.updateItems()
    }
}

extension ViewController : UITableViewDelegate {
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        let item = dataSource[indexPath]
        return item.rowHeight
    }

    func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
        let section = dataSource[section]
        return section.model.headerHeight
    }

    func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
        let section = dataSource[section]
        return section.model.footerHeight
    }

    func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
        let headerView = UIView()
        headerView.backgroundColor = .clear
        return headerView
    }

    func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
        let footerView = UIView()
        footerView.backgroundColor = .clear
        return footerView
    }
}

lazy プロパティ

・参照されるときに初めて初期値が設定されるプロパティ
・レイジープロパティに代入する値は、変数に限らず、メソッドを呼び出して値を設定することができる

[備考]
・クロージャを使ってレイジープロパティを設定することもできる

var price = 5000

class MyItem {
    lazy var price:Int = { price * 2 }()
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【アプリ開発】はじめての個人開発に意気込み過ぎたら色々しくじった

はじめに

とある受託企業でiOS開発をしています。
個人開発のしくじり記事です。noteとqiitaで迷いましたが、Gitの共有もしたいのでこの場をお借りして書かせて頂きます。

開発の経緯

業務である程度のことなら開発ができるようになってきたということもあり、個人開発にチャレンジしたいと思い立つ。
それと同時に最近ピーター・ドラッカーの"フィードバック分析"を紙ベースで行なっていたので、どうせならアプリ化しようと思い開発に取り掛かる。

フィードバック分析とは自分の強みを知り、その強みを伸ばしていくためのメソッド。ピーター・ドラッカー自身も長年このメソッドを取り入れていた。

開発前の準備

特に何もしていません。
思い立ったら即行動。

開発初期

~ なんでもできる万能感 ~

TwitterやFacebookのUIをみて、自前で全部作れるわとか思っていた頃。
また、フィードバック分析のアプリがなかったということもあり、市場のパイオニアのような感覚だった。

これがブルーオーシャンww

とか思っていた。とにかくイタすぎた。

開発中期

~ 割と楽しかった ~

妄想フェーズは終わって、現実的にデータベース設計とかアーキテクチャの学習とかやっていた。
市場のパイオニア感をモチベーションに難しい実装も幾度となく乗り越え、その度に達成感に包まれた。
この頃が一番精神的に豊かだった。

開発後期

~ モチベーション氷河期 ~

モチベーション大暴落。側から見たら情緒不安定。

理由1. こんなアプリじゃない感

なぜかアプリがSNS寄りになっていく。そして指が勝手に動く。気づいたらSNSになっていた。
この時に、機能定義などは開発前に着手するべきだと痛感した。

理由2. 先が見えない

後半から気づき始めたが、初期フェーズの個人が出すアプリにしては規模がデカすぎる。
何も考えず開発を続けていると、あれもこれも必要なんじゃないか とか こんな機能がほしい と、つい目的から遠ざかってしまう。それが行き過ぎたのが俺だ。
これに関しても事前準備である程度は回避できたはずだ。


そして自分は一体なんのために戦っているのか、何と戦っているのかわからなくなってきた。しかし、一つだけ目的があった。

それが"リリース"。

自分で作ったアプリを市場に出せば何かが変わるんじゃないかという想いで無心で頑張った。
それはまるで仲間を失い生贄の烙印を押されながらも、グリフィスを倒すというただ一つの目的のために戦い続けるガッツのように。

反省点

・ アプリは小さく始める

今回の記事で一番主張したい。調子に乗るなと。一人のエンジニアのリソースで世の有名アプリと張り合えるアプリなど作り出せない。
誰でも最初はすごいものを作りたい。その気持ちはわかる。だが、"過ぎ去るは及ばざるが如し"というように、そんなことをするとコードの前に精神が破綻する。それが俺だ。

・ 画面遷移図やデータの流れをざっくりとでも書き起こす

ざっくりとでもデータの流れや画面遷移の流れを書き起こすべきだった。自分の場合局所的に必要なデータや画面を都度書き起こしたりしていて時間と労力の無駄が多かった。

・ 個人開発者の記事をいくつか見て取り組むべきだった

すでに世の中には沢山のしくじり先生がいる。 
同じ失敗をする必要はなかった。

技術的な反省点

・ Viewの管理

アプリで同じようなviewを使い回す場面が多かった。しかしそれはあらかじめ把握できたものだ。自分は愚策にもBaseViewなるものを作ってこの問題を解決してしまった。反省はしている
BaseViewControllerは作りたくない

・ 変数名をもっとしっかりつけるべきだった

最初は具体的な変数名をしっかりつけていたが、いつの間にか

・token
・documentID

などという抽象的な変数名をつけるようになった。一周回ってこんな変数名つける俺かっこいいとか思ってたけど後からコード全体見渡したら地獄絵図だった。
変数名は長くなってもわかりやすく。
リーダブルコード

・ どういう目的でどこに処理を書くか

どこにどういう目的で処理を書くかなど最初は何も考えなかった。
しかし、規模が大きくなるとそれらの負債は自分を襲ってくる。
やられる前に対策しよう。
ひどいコードをメンテしてきたからこそ実感する、良いコードや良い設計の大切さ
コーディング原則
デザインパターン

・ deinitが走らない(循環参照)

deinitとはクラスインスタンス解放時に呼ばれる関数。この関数が呼ばれなければ、クラスのインスタンスがメモリ上にまだ残っていることを示唆している。メモリリークの原因になる。

ある時インスタンスが破棄されたタイミングで処理を走らせたい場面があった。
しかしdeinitが走らない(循環参照)、冷や汗をかいた、そしてLintの大切さをしみじみと感じた。
デバックは出来たもののその時間はもう帰ってこない。
それ以外にもLintを導入する理由は数えきれない。
(Lintを入れたらいいのではと思われるかもしれないが、入れた際の地獄絵図を想像するだけでも泣きそうになるから入れなかった。今回だけ許して)
ハマりがちな循環参照について
SwiftLint
私がLintを愛する3つの理由

その後

一応完成はしたものの、リリースは見送る形にしました。

理由は以下です。
・フィードバック分析機能がもはや機能していない。と言うよりも存在にすら気づかない。
・結果SNSになり、それでもリリースしようと思ったが、同じようなアプリはかなりある。
・次に作りたいものがあるので、申請に時間を費やしたくない。
・etc..

しかし、そのままでは何かすごくモヤモヤした気持ちが残るので、せめてこれからiOSで個人開発に取り掛かろうと思っている方々にこれを活用してほしいと思い、Githubに公開しています。
*諸々修正はしましたので、ある程度参考にできるコードにはなっていると思います。
よかったらご覧ください。

最後に

以上しくじりでした。
しかし今回の開発で学んだことは計り知れす、ナレッジも沢山溜まりました。
今回の反省点を全て活かし、これからもめげずにアプリ開発を続けていきます!
その度に個人開発の開発・運用状況なども共有していきたいと思っているのでTwitterもフォローしていただければ嬉しいです。
YUKI_個人アプリ開発&MobileEngineer

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

iOSアプリが立ち上がるまでの処理内容

目的

iOSがbuildされて、どのように立ち上がっているのかという根本的な仕組みを見直す。

iPhoneアプリが起動するまで

#import
#import "AppDelegate.h"

int main(int argc, char *argv[]) {
  @autoreleasepool {
  //AppDelegateのクラス名を指定
    return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
  }
}

上記のように、main関数を実行することによりアプリが起動します。この点に関しては、CやC++のソースコードと同じになります。
UIApplicationMain関数を実行する際に、AppDelegateのクラス名を指定しているため、AppDelegateでアプリ起動時・起動後に処理を実行できるようになります。
次にAppDelegateクラスの処理内容です。
この処理で、アプリの起動後やアプリがバックグラウンドに移動する際などに、処理を実行することを可能にします。

// AppDelegate.swift
import UIKit

// Main関数の自動実装
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

//アプリを起動する際に呼ばれるメソッド
    func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
        // Override point for customization after application launch.
        return true
    }

    // 中略
}

上記がAppDelegateのソースコードです。
上から4行目を見ると、@UIApplicationMainという記述があります。これはmain関数を自動実装してくれるものであり、iOSアプリではmain.swift ファイルを用意しなくても、このコードを記述することでmain.swiftファイルを省略できます。そのため最初に記述した、main.swiftのファイルは実際はxcode上にない場合もあるので注意してください。

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

【Flutter】Kotlin/Swift から Flutter の処理を呼び出す

Flutter から Kotlin/Swift を呼び出す方法は公式にも書いてありますが、 Kotlin/Swift 側から Flutter の処理を呼び出す方法が見つからなかったので、メモとして残しておきます。

Flutter -> Kotlin/Swift では、Flutter 側で invokeMethod 呼び出し、Kotlin/Swift 側で Handler を設定しますが、基本的にこれが逆になって Kotlin/Swift 側で invokeMethod 呼び出し、Flutter 側で Handler を設定となるだけです。

Flutter 側の処理

MethodChannel を作成するところまでは、 Flutter -> Kotlin/Swift 呼び出しと同じです。

import 'dart:async';

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

...

class _MyHomePageState extends State<MyHomePage> {
  static const platform = const MethodChannel('ChannelName');
}

MethodCallHandler を設定する

platform.setMethodCallHandler(_handleMethod);

...

Future<dynamic> _handleMethod(MethodCall call) async {
  switch(call.method) {
    case 'methodName':
      // ここに Flutter 側で実行したい処理を書く。
      debugPrint(call.arguments); // 引数は arguments から取得可能
      return Future.value('');
  }
}

Kotlin 側の処理

val channel = MethodChannel(flutterView, "ChannelName")
channel.invokeMethod("methodName", "message from kotlin")

Swift 側の処理

let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
let channel = FlutterMethodChannel(name: "ChannelName", binaryMessenger: controller)
channel.invokeMethod("methodName", arguments: "message from swift")

サンプル

アプリを起動して10秒後に Kotlin/Swift から Flutter にメッセージを送信して Flutter 側で表示するだけのサンプルコードです。

Flutter

main.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  static const platform = const MethodChannel('ChannelName');
  String _message = "ここに Kotlin/Swift 側から送られてきたメッセージが表示されます。";

  @override
  void initState() {
    super.initState();
    platform.setMethodCallHandler(_handleMethod);
  }

  Future<dynamic> _handleMethod(MethodCall call) async {
    switch(call.method) {
      case 'methodName':
        final String message = call.arguments;
        setState(() {
          _message = message;
        });
        return Future.value('');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              '$_message',
            ),
          ],
        ),
      ),
    );
  }
}

Kotlin

MainActivity.kt
package com.example.native_to_flutter

import android.os.Bundle
import android.os.Handler
import android.os.Looper

import io.flutter.app.FlutterActivity
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.GeneratedPluginRegistrant
import java.util.*
import kotlin.concurrent.schedule

class MainActivity: FlutterActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    GeneratedPluginRegistrant.registerWith(this)

    Timer().schedule(10 * 1000) {
      Thread(Runnable {
        val channel = MethodChannel(flutterView, "ChannelName")
        Handler(Looper.getMainLooper()).post {
          channel.invokeMethod("methodName", "Kotlin からのメッセージ")
        }
      }).start()
    }
  }
}

Handler(Looper.getMainLooper()).post{} の中に処理を書いているのは、 Flutterはシングルスレッドなので、Kotlin 側がサブスレッドで invokeMethod を実行してしまうと例外で落ちてしまうので、メインスレッドで実行させるために、このようにしています。

Swift

AppDelegate.swift
import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?
  ) -> Bool {
    Timer.scheduledTimer(timeInterval: 10, target: self, selector: #selector(AppDelegate.timer), userInfo: nil, repeats: false)

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  @objc func timer() {
    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    let channel = FlutterMethodChannel(name: "ChannelName", binaryMessenger: controller)
    DispatchQueue.main.async {
      channel.invokeMethod("methodName", arguments: "Swift からのメッセージ")
    }
  }
}

こちらもメインスレッドで実行させるため DispatchQueue.main.async{} の中に処理を書いています。

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

[Swift] Arrayに入ったstructの特定のプロパティの重複を削除したい distinct

例えば

struct Receipt {
   let buyer: String
   let product: String
   let date: Date
   let price: Int
}

というデータの配列があった時、buyerプロパティだけ重複を取り除くという時に使います。

Arrayのextensionとして実装しました。

extension Array {
    func distinct<Value: Hashable>(keyPath: KeyPath<Element, Value>) -> Array {
        var set = Set<Value>()
        return filter { set.insert($0[keyPath: keyPath]).inserted }
    }
}

使い方は単純に

let receipts: [Receipt] = // ここに入ってる

receipts.distinct(keyPath: \.buyer)

とするだけです。
簡単ですね。

ちょっと複雑に「各々の人について最も高い買い物の商品名順リスト」を取得するには

let receipts: [Receipt] = // ここに入ってる

let expensiveList = receipts
    .sorted { $0.price > $1.price }
    .distinct(keyPath: \.buyer)
    .sorted { $0.product < $1.product }

という感じになります。

すっきり。

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

iOS UIKitによるPopoverの表示およびカスタマイズ

はじめに

Popover の表示とカスタマイズ方法について調査したので、備忘のために残しておきます。

環境

  • Xcode 10.3 (iOS SDK 12.4)
  • Swift 5

サンプルコード

https://github.com/imamurh/PopoverExample

popover1.png

Popover の表示

UIPopoverPresentationController を利用します。UIViewController のプロパティからアクセスできます。

UIViewController
    @available(iOS 8.0, *)
    open var popoverPresentationController: UIPopoverPresentationController? { get }

UIBarButtonItem に Popover を表示する場合は、barButtonItem プロパティを使用します。

Popoverの表示(UIBarButtonItem)
class ViewController: UIViewController {

    @IBAction func barButtonItemTapped(_ sender: UIBarButtonItem) {

        let vc = ... // Popover で表示したい ViewController を生成

        vc.modalPresentationStyle = .popover
        vc.popoverPresentationController?.barButtonItem = sender
        vc.popoverPresentationController?.permittedArrowDirections = .up // 矢印の向きを制限する場合
        vc.popoverPresentationController?.delegate = self // Phone で Popover を表示するために必要
        present(vc, animated: true)
    }
}

UIButton など任意の UIView に Popover を表示する場合は、sourceRect プロパティと sourceView プロパティを使用します。

Popoverの表示(UIView)
class ViewController: UIViewController {

    @IBAction func buttonTapped(_ sender: UIButton) {

        let vc = ... // Popover で表示したい ViewController を生成

        vc.modalPresentationStyle = .popover
        vc.popoverPresentationController?.sourceView = sender.superview
        vc.popoverPresentationController?.sourceRect = sender.frame
        vc.popoverPresentationController?.permittedArrowDirections = .up // 矢印の向きを制限する場合
        vc.popoverPresentationController?.delegate = self // Phone で Popover を表示するために必要
        present(vc, animated: true)
    }
}

Phone で Popover を表示するためには、UIPopoverPresentationControllerDelegate(が継承しているUIAdaptivePresentationControllerDelegate)の下記メソッドの実装が必要になります。

extension ViewController: UIPopoverPresentationControllerDelegate {
    // Phone で Popover を表示するために必要
    func adaptivePresentationStyle(for controller: UIPresentationController,
                                   traitCollection: UITraitCollection) -> UIModalPresentationStyle {
        return .none
    }
}

以上が基本的な Popover の表示方法になります。

Popover のカスタマイズ

ここからが本題。

背景色

まずは背景色のカスタマイズです。

UIPopoverPresentationController
    // Set popover background color. Set to nil to use default background color. Default is nil.
    @NSCopying open var backgroundColor: UIColor?

UIPopoverPresentationController に backgroundColor というプロパティがあるので、これを設定することで背景色を変更できます。

背景色を指定
        vc.modalPresentationStyle = .popover
        vc.popoverPresentationController?.barButtonItem = sender
        vc.popoverPresentationController?.permittedArrowDirections = .any
+       vc.popoverPresentationController?.backgroundColor = .red
        vc.popoverPresentationController?.delegate = self

backgroundColor を設定しても、表示するコンテンツの背景色を透過にしなければ下記のように矢印部分しか赤くなりません。(当然といえば当然ですが...)

View の階層は下記の通り。

Popover で表示する ViewController の背景色を透過にすれば、下記の通り全体が背景色つきで表示されます。

なお、背景の View には UIVisualEffectView が使用されているため、透過色を指定した場合ブラーがかかります。(下記は UIColor.clear を指定した場合)

ブラーがかかっても問題ない場合は backgroundColor プロパティを使用すれば十分ですが、ブラーを避けたい場合は次に紹介する BackgroundView をカスタマイズする方法を選択する必要があります。

BackgroundView のカスタマイズ

UIPopoverBackgroundView のサブクラスを作成し、UIPopoverPresentationController の popoverBackgroundViewClass に指定することで、独自の BackgroundView を利用することができます。

UIPopoverPresentationController
    // Clients may customize the popover background chrome by providing a class which subclasses `UIPopoverBackgroundView`
    // and which implements the required instance and class methods on that class.
    open var popoverBackgroundViewClass: UIPopoverBackgroundViewMethods.Type?
カスタムBackgroundViewクラスを指定
        vc.modalPresentationStyle = .popover
        vc.popoverPresentationController?.barButtonItem = sender
        vc.popoverPresentationController?.permittedArrowDirections = .any
+       vc.popoverPresentationController?.popoverBackgroundViewClass = MyPopoverBackgroundView.self
        vc.popoverPresentationController?.delegate = self

カスタム BackgroundView クラスでは、適宜必要なメソッドを override する必要があります。最低限必要なメソッドは下記の通りです。(これらを実装しないと実行時にクラッシュします)

class MyPopoverBackgroundView: UIPopoverBackgroundView {

    // MARK: - UIPopoverBackgroundViewMethods

    override static func arrowBase() -> CGFloat {
        return 20
    }

    override static func arrowHeight() -> CGFloat {
        return 10
    }

    override static func contentViewInsets() -> UIEdgeInsets {
        return .zero
    }

    // MARK: - UIPopoverBackgroundView properties

    private var _arrowOffset: CGFloat = 0
    override var arrowOffset: CGFloat {
        get { return _arrowOffset }
        set { _arrowOffset = newValue }
    }

    private var _arrowDirection: UIPopoverArrowDirection = .unknown
    override var arrowDirection: UIPopoverArrowDirection {
        get { return _arrowDirection }
        set { _arrowDirection = newValue }
    }
}

上記最小構成だけ実装した場合の Popover は下記の通りです。

(なんか黒い...)
デフォルトで layer の shadowOpacity が設定されるので、layoutSubviews などで 0 を設定します。

影を消す
class MyPopoverBackgroundView: UIPopoverBackgroundView {
    override func layoutSubviews() {
        super.layoutSubviews()
        layer.shadowOpacity = 0
    }
}

さらに backgroundColor を指定します。

backgroundColor
    override func layoutSubviews() {
        super.layoutSubviews()
        layer.shadowOpacity = 0
+       backgroundColor = UIColor(white: 1, alpha: 0.9)
    }

ここで、実際の Popover のコンテンツの領域は下記赤枠で囲われた部分になります。上の余白は、矢印を描画するための領域となります。

矢印の大きさ(arrowHeight および arrowBase)は、カスタム BackgroundView クラスで実装したクラスメソッドの固定値となります。矢印を描画する方向(arrowDirection)と位置(arrowOffset)は状況に応じて Popover コントローラにより設定されるため、これらの値をもとに矢印を描画します。

arrow.png

下記は CAShapeLayer を用いて角丸と矢印を描画するサンプルになります。(矢印は上方向のみ対応)

class MyPopoverBackgroundView: UIPopoverBackgroundView {

    override func layoutSubviews() {
        super.layoutSubviews()
        layer.shadowOpacity = 0
        setupPathLayer()
    }

    // MARK: - UIPopoverBackgroundViewMethods

    override static func arrowBase() -> CGFloat {
        return 20
    }

    override static func arrowHeight() -> CGFloat {
        return 10
    }

    override static func contentViewInsets() -> UIEdgeInsets {
        return .zero
    }

    // MARK: - Overriding UIPopoverBackgroundView properties

    private var _arrowOffset: CGFloat = 0
    override var arrowOffset: CGFloat {
        get { return _arrowOffset }
        set { _arrowOffset = newValue }
    }

    private var _arrowDirection: UIPopoverArrowDirection = .up
    override var arrowDirection: UIPopoverArrowDirection {
        get { return _arrowDirection }
        set { _arrowDirection = newValue }
    }

    // MARK: - Drawing

    private func setupPathLayer() {
        layer.sublayers?.forEach { $0.removeFromSuperlayer() }

        let rect = bounds
        let pathLayer = CAShapeLayer()
        pathLayer.frame = rect
        pathLayer.path = generatePath(rect, cornerRadius: 10).cgPath
        pathLayer.fillColor = UIColor(white: 1, alpha: 0.95).cgColor
        pathLayer.strokeColor = UIColor(white: 0.8, alpha: 1).cgColor
        pathLayer.lineWidth = 2
        layer.addSublayer(pathLayer)
    }

    private func generatePath(_ rect: CGRect, cornerRadius: CGFloat) -> UIBezierPath {
        let insets: UIEdgeInsets = {
            var insets = MyPopoverBackgroundView.contentViewInsets()
            if _arrowDirection == .up {
                insets.top += MyPopoverBackgroundView.arrowHeight()
            }
            return insets
        }()
        let topLeft     = CGPoint(x: insets.left,               y: insets.top)
        let topRight    = CGPoint(x: rect.maxX - insets.right,  y: insets.top)
        let bottomRight = CGPoint(x: rect.maxX - insets.right,  y: rect.maxY - insets.bottom)
        let bottomLeft  = CGPoint(x: insets.left,               y: rect.maxY - insets.bottom)

        let path = UIBezierPath()
        path.move(to: CGPoint(x: topLeft.x + cornerRadius, y: topLeft.y))
        if _arrowDirection == .up {
            let arrowBase = MyPopoverBackgroundView.arrowBase()
            let arrowCenterX = rect.size.width / 2 + _arrowOffset
            path.addLine(to: CGPoint(x: arrowCenterX - arrowBase / 2, y: insets.top))
            path.addLine(to: CGPoint(x: arrowCenterX, y: 0))
            path.addLine(to: CGPoint(x: arrowCenterX + arrowBase / 2, y: insets.top))
        }
        path.addLine(to: CGPoint(x: topRight.x - cornerRadius, y: topRight.y))
        path.addQuadCurve(to: CGPoint(x: topRight.x, y: topRight.y + cornerRadius), controlPoint: topRight)

        path.addLine(to: CGPoint(x: bottomRight.x, y: bottomRight.y - cornerRadius))
        path.addQuadCurve(to: CGPoint(x: bottomRight.x - cornerRadius, y: bottomRight.y), controlPoint: bottomRight)

        path.addLine(to: CGPoint(x: bottomLeft.x + cornerRadius, y: bottomLeft.y))
        path.addQuadCurve(to: CGPoint(x: bottomLeft.x, y: bottomLeft.y - cornerRadius), controlPoint: bottomLeft)

        path.addLine(to: CGPoint(x: topLeft.x, y: topLeft.y + cornerRadius))
        path.addQuadCurve(to: CGPoint(x: topLeft.x + cornerRadius, y: topLeft.y), controlPoint: topLeft)

        return path
    }
}

実行結果

以上です。

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