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

まだ、CollectionViewで疲弊してるの?iOS12から始めるCompositionalLayout~実装から設計まで~

どうもこんにちは、忘年会をなかなか開催できず今年は悲しいTOSHです。
本日はZOZOテクノロジーズアドベントカレンダー17日目を担当させてもらいます!

はじめに

さて、iOSエンジニアなら誰でも、CollectionViewを使っていますよね。
ただ、皆さん薄々感づいているように、アプリのUIは依然と比べて、ますます複雑なものになってきています。

以下例

AbemaTV RakutenNBA UberEats AppleStore
IMG_9967.PNG IMG_9966.PNG IMG_9965.jpg IMG_9964.PNG

エンジニアからしたら、これらを実装するのに、まず、全体をTableViewでおいて、そのCellのなかにCollectionViewをおいて、その中でHorizontalになるように全体をLayoutって...難しいですよね。
そして何より、どんどん中身をネストしていくっていうのはやっぱり大変。
Apple様も薄々そんなことには気づいており、WWDC19では、CompositionalLayoutを新しく発表しました。
しかし、、、対応しているのはiOS13以降。業務だとiOS12を切るという選択肢はなかなか難しく、結局、力技で実装することになる。
そんな皆様に朗報です!弊社技術顧問の岸川さんが、iOS12でもCompositionalLayoutを使用できる、ライブラリを作成してくれていました!
https://github.com/kishikawakatsumi/IBPCollectionViewCompositionalLayout
ということで、この記事では、実際に運用する上で、どのような設計で作成するとうまく使用しやすいのかを紹介していきたいと思います〜

ちなみに、AppStoreでも使用されているバナーを中央で止める方法はCookPadさんが紹介をしていますが、まあ大変。。。

前提

- CollectionViewを普段から使用している人
- 最適な設計を探している人
- CompositionalLayoutに初めて挑戦する人

CompositionaLayoutの基本概念

詳しい内容については、こちらを参考にしてください。
https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/layouts

*Appleのホームページより引用

特にカスタムItemにUIcollectionViewCellを乗せていくイメージでいいと思います。

実装方法

では、実際に実装をしていきましょう。

各セクションの設定

まずは、セクションのProtocolを作成します。

Section.swift
protocol Section {
    // 各セクションにおくアイテムの数
    var numberOfItems: Int { get }
    // 各セクションのアイテムがタップされた際の処理
    // クロージャーで設定をしておくと、VC側から処理を追加できる
    var didSelectItem: ((Int) -> Void)? { get }
    // ここで、実際にレイアウトを組んでいきます
    func layoutSection() -> NSCollectionLayoutSection
    // Itemとして使用するCellの設定はここで行います。
    func configureCell(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell
}

VC側での初期設定

VC側では、先ほど作成した、Sectionの配列を使用するイメージです。

ViewController.swift
import UIKit

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

        collectionView.delegate = self
        collectionView.dataSource = self
    }

    @IBOutlet weak var collectionView: UICollectionView!

    // Sectionのレイアウトをここでセットできる形にする
    private var collectionViewLayout: UICollectionViewLayout {
        let sections = self.sections
        let layout = UICollectionViewCompositionalLayout { (sectionIndex, environment) -> NSCollectionLayoutSection? in
            return sections[sectionIndex].layoutSection()
        }
        return layout
    }

    private var sections: [Section] = [] {
        didSet {
            // sectionsが更新されたらレイアウトも更新する
            collectionView.collectionViewLayout = collectionViewLayout
            collectionView.reloadData()
        }
    }
}

// dataSourceの設定
extension ViewController: UICollectionViewDataSource {
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return sections.count
    }

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return sections[section].numberOfItems
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        return sections[indexPath.section].configureCell(collectionView: collectionView, indexPath: indexPath)
    }
}

// delegateの設定
extension ViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        guard let didSelectItem = sections[indexPath.section].didSelectItem else {
            return
        }
        didSelectItem(indexPath.row)
    }
}

実際にSectionを作成する

今回、Item用のcellは予め作成しておいた前提で進めます。

ItemsSection.swift
// Sectionを継承します
struct ItemsSection: Section {
    var didSelectItem: ((Int) -> Void)?

    private var items: [Items] = []
    var numberOfItems: Int {
        self.items.count
    }

    func layoutSection() -> NSCollectionLayoutSection {

        // Itemについてのレイアウト設定
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        // groupについてのレイアウト設定
        let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(UIScreen.main.bounds.width - 40), heightDimension: .absolute(184))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

        // Sectionについてのレイアウト設定
        let section = NSCollectionLayoutSection(group: group)
        section.interGroupSpacing = 10
        section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)
        // ここでスクロールストップするのか、しないのかの設定を行う
        section.orthogonalScrollingBehavior = .groupPaging

        return section
    }

    func configureCell(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ItemCollectionViewCell.self), for: indexPath) as! ItemCollectionViewCell
       // ここでCellの設定を行う

        return cell
    }
}

extension BannerSection {
    // Initializerが必要な場合は、Extensionに切り出すと良いでしょう
    init() {
    }
}

各セクションごとに、セクションのStructを一つ作成すると良いでしょう。

実際に、VC側に追加する

それでは先ほど作成した、Sectionを追加していきましょう。

ViewController.swift
func viewDidLoad() {
    ~~省略~~

    collectionView.register(ItemCollectionViewCell.self,
                            forCellWithReuseIdentifier: String(describing: ItemCollectionViewCell.self))

    var itemsSection = ItemsSection()
    itemsSection.didSelectItem = { index in
        // ここで、cell選択時の処理を行う
    }
    sections.append(itemsSection)
}

これで、横にスクロースしながら、中央で止まるCollectionViewを作成することができました。

これらをうまく使用すると下記画像のようなレイアウトも作成することができます!(すみません、時間がなくて図でのイメージになります汗)

[]の中の数字は、[Section番号、 row番号]になります。
これの大事なメリットとしては、TableViewのなかにCollectionViewのようなネストをすることなく、すべて一つのCollectionViewの上で管理することができるというのが大きなメリットかなと思います!
複雑なレイアウトでも、管理しやすい形で、設計を行うことができます!

おまけ

各SectionにHeader,Footerを付けたい!という人も多いかと思います。
Header,Footerを作成する方法は大きく分けて二通りあります。

  1. Sectionに対して、Header,Footerを設定する
  2. Sectionの上下にHeader, FooterとなるSectionを追加する。

これらの方法にはメリットデメリットがあると思いますが、Header, Footerに対して、複雑なタッチイベントを追加したい場合は2の方法がよく、特にタッチイベントを使用しないまたは、タップによってアコーディオンのような処理のみしか行わない場合であれば、1の方法の方が楽なのかなと思います。

1の方法の実装方法

先ほどのSectionに対して、もう一つメソッドを追加します。

Section.swift
Protocol Section {
    ~~省略~~

    // HeaderやFooterを使用しない場合は、UICollectionReusableView()を返す
    func configureHeaderFooter(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
}

次にItemsSectionにも新しく追加したメソッドを足しましょう!

ItemsSection.swift
struct ItemsSection: Section {
    func layoutSection() -> NSCollectionLayoutSection {
           ~~省略~~
            // header
            let headerSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .estimated(95))
            let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
                layoutSize: headerSize,
                elementKind: "section-header-element-kind",
                alignment: .top)

            // footer
            let footerSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .estimated(45))
            let sectionFooter = NSCollectionLayoutBoundarySupplementaryItem(
                layoutSize: footerSize,
                elementKind: "section-footer-element-kind",
                alignment: .bottom)

            // header, footerを追加
            section.boundarySupplementaryItems = [sectionHeader, sectionFooter]
          ~~省略~~
    }

    func configureHeaderFooter(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        // ここでの文字列は固定
        switch kind {
        // header
        case "section-header-element-kind":
            let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: String(describing: ItemHeaderCell.self), for: indexPath) as! ItemHeaderCell
            // Headerのセットアップ
            return header
        // footer
        case "section-footer-element-kind":
            let footer = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: String(describing: ItemFooterCell.self), for: indexPath) as! ItemFooterCell
        // Footerのセットアップ
        return footer
        default:
            // HeaderやFooterを設定しない場合には、UICollectionReusableView()を返す
            return UICollectionReusableView()
        }
    }
}

最後に、VC側

ViewController.swift
final class ViewController: UIViewController {
    override func viewDidLoad() {
    ~~省略~~
        collectionView.register(itemHeaderCell.self, forSupplementaryViewOfKind: "section-header-element-kind", withReuseIdentifier: String(describing: itemHeaderCell.self))
        collectionView.register(itemFooterCell.self, forSupplementaryViewOfKind: "section-footer-element-kind", withReuseIdentifier: String(describing: itemFooterCell.self))
    ~~省略~~
    }
}

extension ViewController: UIViewControllerDataSource {
    func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        let view = sections[indexPath.section].configureHeaderFooter(collectionView, viewForSupplementaryElementOfKind: kind, at: indexPath)
        // viewにタップジェスチャを追加したい場合にはここで行う
        return view
    }
}

まとめ

こんな感じで、ViewControllerとSectionに分けて実装するとなかなかいい設計になるのではないでしょうか?
先ほどのべたライブラリを使用するとiOS12からCompositionalLayoutが使用できるので、ぜひ少しずつ使用し始めてください〜
ちなみに、iOS12のサポート終了後でも容易に移行できます。

今回はDataSourceは通常のものを使用しましたが、CompositionalLayoutと同時に、DiffrableDataSourceも登場しているので、こちらと組み合わせるとまた別の設計も組めるのではないでしょうか?
DiffrableDataSourceについてはまた今度記事にしようと思います!
それではっ!

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

【Swift】Map Kit Viewを使った基本機能のテンプレートと解説

これはなに

Map Kit View を設置したときコントローラに書くテンプレートのようなものとその解説。
ひとつの画面に Map Kit ViewLabel があり、マップの特定箇所を長押しするとLebel部分に住所が表示されるという想定。

テンプレートと解説

ViewController.swift
import UIKit

// 以下2つが Map Kit View を使用する上で必要
import MapKit
import CoreLocation

// 2つのプロトコルを宣言
class ViewController: UIViewController,CLLocationManagerDelegate, UIGestureRecognizerDelegate {

  // 住所が入る変数を定義
  var addressString = ""

  // 長押しを認識させる
  @IBOutlet var longPress: UILongPressGestureRecognizer!

  // ストーリーボードから Map Kit View をControlキー長押しでつなぐ
  @IBOutlet weak var mapView: MKMapView!

  // 位置情報を取得するインスタンスを作成
  var locationManager:CLLocationManager!

  // ストーリーボードから Label をControlキー長押しでつなぐ
  @IBOutlet weak var addressLabel: UILabel!

  // 今回特に関係なし
  override func viewDidLoad() {
    super.viewDidLoad()
  }

  // ストーリーボードで設置した Long Press Gesture Recognizer(詳細は当記事の下の方を参照)
  @IBAction func longPressTap(_ sender: UILongPressGestureRecognizer) {

    // タップを開始したとき
    if sender.state == .began{

    // タップを終了したとき
    } else if sender.state == .ended {

      // タップした位置(CGPoint)を指定してMKMapViewの緯度経度を取得
      let tapPoint = sender.location(in: view)
      let center = mapView.convert(tapPoint, toCoordinateFrom: mapView)

      // 緯度
      let lat = center.latitude

      // 経度
      let log = center.longitude

      // 緯度経度から住所に変換するメソッドに引数を渡す
      convert(lat: lat, log: log)
    }
  }

  // 緯度と経度を変換するためのメソッド
  // このあたりに書いたプロパティは当記事の下の方にあるリンクを見ると理解しやすいかも
  func convert(lat:CLLocationDegrees, log:CLLocationDegrees) {

    // 住所から緯度経度に変換
    let geocoder = CLGeocoder()
    // 緯度経度から住所を作成
    let location = CLLocation(latitude: lat, longitude: log)

    // クロージャー(原則クロージャーの中に入ってるものは self を付けて書く。値が入ったあとにカッコ内が呼ばれ、値が入るまではカッコの外が呼ばれる)
    // 経度、緯度から逆ジオコーディングして住所を取得する
    geocoder.reverseGeocodeLocation(location) {
      (placeMark, error) in

      // 想定通りの住所が取得できた場合
      if let placeMark = placeMark {
        if let pm = placeMark.first {

          // 想定通りの住所が取得できなかった場合は文字列を組み合わせて作る
          if pm.administrativeArea != nil || pm.locality != nil {
            self.addressString = pm.name! + pm.administrativeArea! + pm.locality!
          } else {
            self.addressString = pm.name!
          }
          // Labalに住所を表示
          self.addressLabel.text = self.addressString
        }
      }
    }
  }
}

注釈と参考

Long Press Gesture Recognizer

  1. ストーリーボードでMap Kit Viewの上に重ねて設置する
  2. ストーリーボードのView Controller上部バー(?)に作られたアイコンをcontrolキーを押しながら View Controller.swift 上にドラッグドロップしてつなぐ
  3. Type を下の画像のように UILongPressGestureRecognizer に設定してConnectを押す

f63ae96ff5f16b59d55306b256fe7af5.png

プロパティと中の値について

参考

iOS11でCLPlaceMarkのnameで取れる値が変わったと思って検証したら、奇妙なプロパティであることがわかった
https://qiita.com/fr0g_fr0g/items/356d88ec906f2004f5f6


こんな感じです。
ぼんやりとした理解でちょっとどんくさいコードのような気もします。

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

NavigationBar の戻るボタンをタップしたら、特定の VC に戻りたい!

TL;DR

途中の VC をスタックから外せばいい。

本編

例えばこんなこと、考えたことありませんか?まず画面 A から Push 遷移で画面 B へ、次に画面 B から同じく Push 遷移で画面 C へ遷移しました。このあと、画面 C から戻る操作で B を飛ばして直接画面 A に戻りたい!よくあるのは、例えば画面 B が二段階認証の画面の時、この場合画面 C から戻る場合二段階認証の画面に戻るのはナンセンスなので、その画面を飛ばして前の画面に戻りたい時ですね。

【効果なし】 backBarButtonItem をカスタマイズ

まず考えられるのは NavigationBar の戻るボタンをカスタマイズし、このボタンがタップされたら、自分で navigationController?.popToViewController を実行する方法ですね。しかし残念ながら、NavigationBar の戻るボタンは leftBarButtonItem ではなく backBarButtonItem です。このボタンの動作をカスタマイズするのはできないのです。

あれ?違うよ?navigationItem.backBarButtonItem{ get set } 対応だよ?

はい、そうです。しかし、ここで設定できるのは、次の画面に遷移された時の戻るボタンです。つまり、これは戻り先が自分の時の設定です。さらに backBarButtonItemaction を設定しても、UIKit には無視されます。ここで設定できるのはあくまで backBarButtonItem の表示だけです。

ちなみに UINavigationControllerDelegate に適合し、navigationController?.delegate を自分自身に設定することで戻るボタンの動作のカスタマイズが可能との記事も散見しますが、アレは嘘です。できません。そもそも UINavigationControllerDelegate は遷移先のカスタマイズの責務まで持ちません。

【イマイチ】 leftBarButtonItem を設定

戻るボタンのカスタマイズができないなら、次に考えるのは、leftBarButtonItem で置き換える方法です。確かに、これなら popToViewController が実行可能なので、特定の VC に戻れます。しかし、デメリットも二つあります。

一つ目は戻るボタン特有の < マークが表示されないことです。これは UIKit 共通のマークですので、これをなくすと一貫性がなくなり、特に iOS のヘビーユーザに違和感を覚えられることになります。もちろん無理やり独自で < 画像を作って表示させてあげることも可能は可能ですが、やはり純正の < マークと比べて何か違和感があります。

もう一つは左端からのエッジスワイプによる戻る操作ができなくなることです。これも UIKit 共通のジェスチャー操作であり、特に大画面の iPhone が主流になった今日では必要不可欠と言っても過言ではない仕様の一つです。これがなくなったら、前の画面に戻りたいときは非常に押しづらい左上のバックボタンを押すしかなくなります。もちろんこれも一応独自でエッジスワイプジェスチャーを実装してあげることも可能は可能ですが、やはり面倒くさいです。

そして上記のデメリット以外に、設計によっては画面 A のタイトルを画面 C の leftBarButtonItem で設定しないといけないので、微妙に Fat になったりするのも気持ち悪いですね。

【オススメ】遷移後に飛ばしたい画面をスタックから削除

実はアプローチを変えてみれば、これは意外と非常に簡単に解決できる方法があります:飛ばしたい画面をそもそも navigationController?.viewControllers 配列から削除しちゃえばいいのです。そもそもの話、画面 B を戻るときに飛ばしたいってことは、画面 B はもう要らないってことになります。でしたら、もう画面 C に遷移したら、一つ前の画面 B を削除しちゃえば、NavigationBar が勝手に画面 A に戻すように設定してくれますので、< や画面名の表示とかエッジスワイプのジェスチャー設定とかの面倒な作業は一切やらなくていいので、UIKit の素直な実装で動いてくれるからとても気持ちいいです。

ViewControllerB
    navigationController?.pushViewController(vc, animated: true) // 次の画面 C への Push 遷移
    navigationController?.viewControllers.removeAll(where: { $0 === self }) // その直後に自身を NavigationController の viewControllers スタックから削除

screen0.gif

もちろんこれが完璧と言うわけでもありません、一つだけ細かい問題があります:それは画面 C に遷移した直後に、一瞬だけ画面 B の名前が戻るボタンの場所に表示されることです。表示は一瞬だけで、遷移が終わったら画面 B はスタックから消えるので、画面 A の名前に変わります。

【オルタネイティブ】そもそも Push 遷移しない

ちなみに、アップルの公式アプリでも似たような処理があります、それは設定アプリからパスコードを設定する画面に入るところです。この画面も、パスコードの設定画面に入る前に一回認証処理が挟まり、戻るとき当然ながらその認証画面には行かないで設定一覧画面に戻ります。ではアップルがどうしてるかと言うと、認証画面は Push 遷移ではなく Modal 遷移です、そして認証が通ったら裏でパスコード画面に Push 遷移済みの状態にした上で認証画面を Dismiss します。

このような処理も、見た目として特に違和感なく自然に見えますが、ただしそもそもの話、設定一覧画面から表示されている「遷移先」が「パスコード設定」であり、「認証」ではありません。だからこそ認証画面を Modal 遷移しても特に違和感を覚えません。ユーザは「パスコード設定画面」を「Push 遷移」で期待しているからです。しかし逆に遷移先を「画面 B」と記載しながら Modal 遷移すると、違和感を覚えるでしょう。

後書き

難しそうな機能でも、ときにはちょっとアプローチを変えるだけで、すんなり解決することもあります。

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

【Swift】型の種類〜構造体〜

構造体 とは

構造体は値型の一種であり、
ストアドプロパティの組み合わせによって1つの値を表します。

構造体の利用例は多岐に渡ります。
例えば標準ライブラリで提供されている多くの型は構造体です。

Bool型、数値型、String型、Array<Element>型は全て構造体です。

構造体ではないの型は、
Optional<Wrapped>型、タプル型、Any型などが挙げられます。

定義方法

構造体の定義にはstructキーワードを用います。

struct 構造体名 {
   構造体の定義
}

プロパティやメソッド、イニシャライザなどの
型を構成する要素では全て利用可能で、{ }内に定義します。

struct Sample {
    let number: Int
    let name: String

    init(number: Int, name: String) {
        self.number = number
        self.name = name
    }

    func printStatus() {
        print("名前:\(name)\n番号:\(number)番")
    }
}

let a = Sample(number: 1, name: "相原")
a.printStatus()

実行結果
名前相原
番号1

ストアドプロパティの変更による値の変更

先ほど記載した通り、構造体はストアドプロパティの組み合わせになります。
構造体のストアドプロパティを変更することは構造体を別の値に変更することに等しく、
構造体が入っている定数や変数の再代入を必要とします。

したがって、値型の値の変更に関する仕様は、
構造体のストアドプロパティの変更にも適用されます。

定数のストアドプロパティは変更不可

構造体のストアドプロパティの変更には再代入を必要とするので、
定数に代入された構造体のストアドプロパティは変更することができません。

下記サンプルコードの通りです。

struct Sample {
    var value: Int

    init(value: Int) {
        self.value = value
    }
}

var a = Sample(value: 1)
a.value = 2   // OK

let b = Sample(value: 1)
b.value = 3   // コンパイルエラー

エラー内容:Cannot assign to property: 'b' is a 'let' constant
和訳:プロパティに割り当てることができません:「b」は「let」定数です

メソッド内のストアドプロパティの変更

構造体の中に定義されているメソッドでストアドプロパティを変更するためには、
メソッドの先頭にmutatingキーワードを追加する必要があります。

struct Sample {
    var value: Int

    init(value: Int) {
        self.value = value
    }

    mutating func increment() {
        value += 1
    }
}

var a = Sample(value: 1)
a.increment()
a.value   // 2

メソッド内でのストアドプロパティの変更はコンパイラによってチェックされています。

なので、ストアドプロパティの変更を伴っているメソッドに
mutatingキーワードが追加されていないとコンパイルエラーになります。

下記がmutatingキーワードが追加されていないパターンです。

struct Sample {
    var value: Int

    init(value: Int) {
        self.value = value
    }

    func increment() {
        value += 1   // コンパイルエラー
    }
}

var a = Sample(value: 1)
a.increment()

エラー内容:Left side of mutating operator isn't mutable: 'self' is immutable
和訳:変更演算子の左側は変更できません:「self」は変更できません

メンバーワイズイニシャライザ

型のインスタンスは初期化後に全てのプロパティが初期化されている必要があります。

そのため、プロパティの定義時に初期化したり、
独自のイニシャライザを定義して初期化していました。

ですが、構造体では自動的に定義される
メンバーワイズイニシャライザを利用することができます。

メンバーワイズイニシャライザとは、
型が持っている各ストアドプロパティと同名の引数を取るイニシャライザです。

下記サンプルコードの場合は、
ストアドプロパティがvalueとstatusの2つなので
メンバーワイズイニシャライザはinit(value: Int, status: String)になります。

また、メンバーワイズイニシャライザは、
通常のイニシャライザと同様に使うことができます。

struct Sample {
    var value: Int
    var status: String

    // 自動で生成される
    // init(value: Int, status: String) {
    //     self.value = value
    //     self.status = status
    // }

}

let a = Sample(value: 123, status: "abc")
a.value   // 123
a.status   // abc

メンバーワイズイニシャライザのデフォルト引数

ストアドプロパティが定義時に初期化されている場合、
そのプロパティに対応するメンバーワイズイニシャライザの引数は、
デフォルト引数を持ち呼び出し時の引数の指定を省略できます。

もちろんデフォルト引数を持つだけなので、
通常通り引数を指定することもできます。

次のサンプルコードでは、
ストアドプロパティvalueとstatusを持っている構造体を定義します。

valueは初期化を行い10を代入しています。

この場合メンバーワイズイニシャライザの引数valueには、
デフォルト引数10が定義されます。

struct Sample {
    var value: Int = 10
    var status: String

    // 自動で生成される
    // init(value: Int = 10, status: String) {
    //     self.value = value
    //     self.status = status
    // }
}

let a = Sample(status: "abc")
a.value   // 10
a.status   // abc

let b = Sample(value: 123, status: "def")
b.value   // 123
b.status   // def

以上が構造体についての説明になります。

型の構成要素をうまく使い利便性の高い構造体を作れるようになりたいです。

そのためにはどんどん構造体を利用していくしかないと思うので、
どんどんアウトプットしていきたいと思います!

クラスや列挙型、型の基礎知識についても記事にしていますので、
お時間がある際にぜひご覧ください!

【Swift】型の種類〜基礎知識〜
【Swift】型の種類〜クラス前編〜
・【Swift】型の種類〜列挙型〜

最後までご覧いただきありがとうございました。

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

MacinCloud導入について

MacinCloudって?

スクリーンショット 2020-12-16 18.16.50.png
Windowsでもmacが使えるサービスです!!
MacincloudはMacOSを仮想サーバーのような形で提供しているサイトです!
要は、世界のどこかにあるサーバーにMacOSがあって、それをネットを介して覗いてみて、遠隔操作するってイメージです!
これがあれば、WindowsでもMacOSが使え、XCodeも元々入っているので、iPhoneアプリ開発ができます!
プランによりますが、一番安いプランだと月20ドル~からできるみたいです。
大体30ドルくらいで申し込む人が、僕の周りでは多いです!

早速導入してみましょう!

スクリーンショット 2020-12-17 15.54.56.png

MacinCloudにアクセスして、
TRY IT NOW を押しましょう.

値段の見方

このピンクの四角の部分に値段が書いてあります!
スクリーンショット 2020-12-16 18.27.16.png

各項目についてチェックしていきましょう!

Operating System Version

スクリーンショット 2020-12-16 18.18.11.png
MacOSには何個かバージョンがあります!!
2020/12/16現在ではmacOS Catalina10.15.7がおすすめです!
最新はBigSurですが、リリースされた直後で、これに対応していないアプリがいくつかあるって言う噂です。。
ここに関してはOSについていろんなところを検索して、検討してみてください!

Platform/Models

スクリーンショット 2020-12-17 14.45.31.png

Macのスペックの話です!
良いほど、料金が上がります。
CPUのバージョンなど指定できます。

PCのスペックってざっくり3種類のモノで評価します。
わかりやすくイメージするなら、料理をイメージして下さい!
料理って早く出てきて欲しいモノですよね。
①CPU・・・料理人の腕。これがいいと料理って早く出てきます。画像で言うと、3.6GHz quad-core Intel Core i3の部分。i7とか、iの値が増えると、腕が良いイメージ。
②メモリ(RAM)・・・作業台の広さ。料理人の腕が良くても、作業スペースが狭かったら、すぐキャパります。下のAddonsのところで設定できます。
③ストレージ(ROM)・・・ちょっと遠くにある冷蔵庫の大きさ。冷蔵庫が大きいといろいろできます。ちょっと遠いって言ったのは、メモリの方が料理人の近くにあるのに対して、ストレージは遠くにあるので、材料を撮ってくるのにちょっと時間かかるってことです。MacInCloudにおいては2TBとかアホほど大きいので、気にしなくて大丈夫そうです。

Location

スクリーンショット 2020-12-17 14.56.03.png
多分みなさん、日本に住んでいるので、Asia East (Near Singapore)で大丈夫です。ここは料金に反映しないです。

Paytment Cycle & Login Time

スクリーンショット 2020-12-17 15.48.39.png
いつ払いますか〜ってことです。Monthlyを選択肢しましょう!
MacinCloudは一日にログインできる時間に制限があります!
最低は5時間は欲しいところ、、、
後から変更できるのかな、??

Addons

スクリーンショット 2020-12-17 15.37.19.png

アッドンじゃないよアドオンだよ。
RAMについてはこの記事を見てください!Platform/Modelsの項目で述べた、メモリの話です!
Resolution SupportとInternal/External GPUは画像の通りにチェックしとけば良いと思います!

支払い、個人情報記入

スクリーンショット 2020-12-17 15.46.20.png

案内にしたがって記入してください!

最後入力情報確認して終わり!

です!

リモートデスクトップを行うアプリをまとめてダウンロードできるページがあると思うので
そこから、ダウンロードして、お好みの画面サイズのファイルダブルクリックして、ログインしたら、
環境構築完了!!

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

(書き途中)MacinCloud導入について

MacinCloudって?

Windowsでもmacが使えるサービスです!!
MacincloudはMacOSを仮想サーバーのような形で提供しているサイトです!
要は、世界のどこかにあるサーバーにMacOSがあって、それをネットを介して覗いてみて、遠隔操作するってイメージです!
これがあれば、WindowsでもMacOSが使え、XCodeも元々に入っているので、iPhoneアプリ開発ができます!
プランによりますが、一番安いプランだと月20ドル~からできるみたいです。
大体30ドルくらいで申し込む人が、僕の周りでは多いです!

早速導入してみましょう!

MacinCloudにアクセスして、
TRY IT NOW を押しましょう.
スクリーンショット 2020-12-16 18.16.50.png

値段の見方

このピンクの四角の部分に値段が書いてあります!
スクリーンショット 2020-12-16 18.27.16.png

各項目についてチェックしていきましょう!

Operating System Version

スクリーンショット 2020-12-16 18.18.11.png
MacOSには何個かバージョンがあります!!
2020/12/16現在ではmacOS Catalina10.15.7がおすすめです!
最新はBigSurですが、リリースされた直後で、これに対応していないアプリがいくつかあるって言う噂です。。
ここに関してはOSについていろんなところを検索して、検討してみてください!

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

【これからiOS頑張りたい方向け】2年半iOSアプリ開発をしてハッとした瞬間まとめ

はじめに

iOS Advent Calendar 2020 17日目です。:tada:

2年半くらいiOSアプリ開発してきてハッとした瞬間をまとめました。(iOSとかswiftに限った話じゃない学びもあるけど。)
がんばらなくても読めるけど、なんとなく勉強にもなる記事を目指しました。
タイトルに近い方が初歩的なやつです。
もし時間あればみていただけると嬉しいです。

お品書き

返り値でBoolを返す時はそのBool自身を返せばいい

Before

var hoge: Bool {
    if fugaBool {
        return true
    } else {
        return false
    }
}

After

var hoge: Bool {
    return fugaBool
}

今思えば当たり前なんですけどね。
〇〇がtrueの時trueを返さなきゃってなって〇〇自体が真偽値なことに気づいてなかったんでしょうね。

三項演算子を使うと if else がワンライナーで書ける

Before

if fugaBool {
    hogeLabel.text = "fugaは真です"
} else {
    hogeLabel.text = "fugaは偽です"
}

After

hogeLabel.text = fugaBool ? "fugaは真です" : "fugaは偽です"

三項演算子すこ。

var +=は計算型プロパティにできる。

Before

func presentHogeAlert() {
    var message = ""
    if validationA {
        message.append("Aがあかんかった")
    } else if validationB {
        message.append("Bがあかんかった")
    } else {
        message.append("あかんかった")
    }
    let alert = UIAlertController(title: "エラー", message: message, preferredStyle: .alert)
    alert.addAction(.init(title: "OK", style: .default))
    present(alert, animated: true)
}

After

func presentHogeAlert() {
    var message: String {
        if validationA {
            return "Aがあかんかった"
        } else if validationB {
            return "Bがあかんかった"
        } else {
            return "あかんかった"
        }
    }

    let alert = UIAlertController(title: "エラー", message: message, preferredStyle: .alert)
    alert.addAction(.init(title: "OK", style: .default))
    present(alert, animated: true)
}

計算型の方が返り値がわかりやすくてスッキリしますよね。

ネストは早期returnで減らせる

Before

func hoge() {
    if yagi {
        // 処理A
        if let mogu = mogu {
            // 処理B
        } else {
            if fuga {
                // 処理C
            } else {
                // 処理D
            }
        }
    } else {
        // 処理E
    }
}

After

func hoge() {
    guard yagi else {
        // 処理E
        return
    }

    // 処理A

    guard let mogu = mogu else {
        if fuga {
            // 処理C
        } else {
            // 処理D
        }
        return 
    }

    // 処理B
}

もうちょっといい例を用意したかったです。
ネストが多くて長いだけで読むのが辛くなっちゃいますよね。

2重否定はifでいい。

Before

guard !fugaBool else {
    // do something
    return
}

After

if fugaBool {
    // do something
    return
}

プロジェクトによってはguardで明示的にreturnを表現するためにあえて2重否定を好むところもあります。
真偽値一つならまだ分かるけどこれが!fugaBool || mogu > 1 && yagi.isEmpty みたいな条件になってきたら発狂しますよね。

型が明確な時のinitializerは.initに省略できる

Before

view.frame = CGRect(origin: CGPoint(x: 0, y: 0), size: CGSize(width: 0, height: 0))

After

view.frame = .init(
    origin: .init(
        x: 0,
        y: 0
    ),
    size: .init(
        width: 0,
        height: 0
    )
)

長い型名を省略できるので好き。
多用すると読みにくくなるので注意。

trailing closureは引数から省略できる

Before

func hogeChanged(fugaBool: Bool, hogeCompletionHandler: @escaping ((String) -> Void)) {
    guard fugaBool else { return }

    let hoge = getChangedHogeValue
    hogeCompletionHandler(hoge)
}

hogeChanged(fugaBool: true, hogeCompletionHandler: @escaping { [weak self] hoge in

    guard let self = self else { return }

    self.hogeLabel.text = hoge
})

After

func hogeChanged(fugaBool: Bool, hogeCompletionHandler: @escaping ((String) -> Void)) {
    guard fugaBool else { return }

    let hoge = getChangedHogeValue
    hogeCompletionHandler(hoge)
}

hogeChanged(fugaBool: true) { [weak self] hoge in

    guard let self = self else { return }

    self.hogeLabel.text = hoge
})

クロージャの引数名大抵長くなるから助かります。
読みづらくなる場合もあります。

enumとswitchを組み合わせて網羅性をチェックする

例えばTabBarControllerのtabの種類をenumにするとか。

Before

    override func viewDidLoad() {
        super.viewDidLoad()

        var viewControllers: [UIViewController] = []

        let hogeVC = HogeViewController())
        hogeVC.tabBarItem = UITabBarItem(title: "hoge", image: nil, tag: 1)
        viewControllers.append(hogeVC)

        let fugaVC = FugaViewController()
        fugaVC.tabBarItem = UITabBarItem(title: "fuga", image: nil, tag: 2)
        viewControllers.append(fugaVC)

        setViewControllers(viewControllers, animated: false)
    }

After

enum TabType: Int, CaseIterable {
    case hoge = 0
    case fuga = 1

    private var baseViewController: UIViewController {
        switch self {
        case .hoge:
            return HogeViewController()

        case .fuge:
            return FugaViewController()

        }
    }

    private var title: String {
        switch self {
        case .hoge:
            return "hoge"

        case .fuga:
            return "fuga"

        }
    }

    var tabItem: UITabBarItem {
        .init(title: title, image: nil, tag: self.rawValue)
    }

    var viewController: UIViewController {
        let viewController = baseViewController
        viewController.tabBarItem = tabItem
        return viewController
    }
}

// 使う時
override func viewDidLoad() {
    super.viewDidLoad()
    setViewControllers(TabType.allCases.map(\.viewController), animated: false)
}

caseを追加すれば他の設定値も自ずと追加が必要になるので漏れを防げます。

Bool値が複数ある場合の場合分けはswitchとパターンマッチを使うと見やすくなる。

Before

フラグが何個かあってtrueとfalseの組み合わせを表現したい時。

let hogeFlag: Bool
let fugaFlag: Bool

if hogeFlag && fugaFlag {
    // true true
} else if !hogeFlag && fugaFlag {
    // false true
} else if hogeFlag && !fugaFlag {
    // true flase
} else {
    // false false
}

After

switch (hogeFlag, fugaFlag) {
case (true, true):   break
case (false, true):  break
case (true, false):  break
case (false, false): break
}

switchの見やすさは異常。

var+forは大抵高階関数で書き換えられる

Before

var titles: [String] = []
for i in 0...10 {
    let title = "りんごが\(i)個あります。"
    titles.append(title)
}
titles.joined(separator: "\n")
return titles

After

(0...10).map {
    "りんごが\($0)個あります。"
}
.joined(separator: "\n")

https://qiita.com/shtnkgm/items/600009917d8e572e6780
こちらの記事が詳しいです。

配列のmappingにはmapが使える

Before

struct Hoge {
    let aaa: String
    let iii: String
    let uuu: String
}

// Hogeの配列があります。
let hogeList = [Hoge]()

After

// 特定のプロパティのみ取り出し
let aaaList = hogeList.map(\.aaa)
// 別の型にmapping
struct Fuga {
    let aaa: String
    let eee: String
    let ooo: String
}
let fugaList = hogeList.map { Fuga(aaa: $0.aaa, eee: "", ooo: "") }

API叩いてデータ加工してViewに渡す過程で絶対必要になりますよね。

for文にifを挟む処理はfor+whereに書き換えできる。

こういう動物達がいるとします。

protocol Animal {
    var name: String { get }
}

protocol Runable: Animal {
    func run()
}
extension Runable {
    func run() {
        print("run!!!")
    }
}

class Cat: Animal, Runable {
    var name: String = "猫"
}

class Dog: Animal, Runable {
    var name: String = "犬"
}

class Penguin: Animal {
    var name: String = "ペンギン"
}

let animals: [Animal] = [Cat(), Dog(), Penguin()]

Before

for animal in animals {
    if animal is Runable {
        print("この動物は走れます!")
    }
    if let runableAnimal = animal as? Runable {
        runableAnimal.run()
    }
}

After

for animal in animals where animal is Runable {
    print("この動物は走れます!")

    if let runableAnimal = animal as? Runable {
        runableAnimal.run()
    }
}

whereを使いこなしてるとなんかできる感出てきますよね。

for+wherefilter+forEachに書き換えできる。

先ほどの例から

Before

for animal in animals where animal is Runable {
    print("\(animal.name)は走れます!")

    if let runableAnimal = animal as? Runable {
        runableAnimal.run()
    }
}

After

animals.filter { $0 is Runable }.forEach {
    print("\($0.name)は走れます!")

    if let runableAnimal = $0 as? Runable {
        runableAnimal.run()
    }
}

高階関数すこ。
この例ならキャストしてcompactMapにかけた方が早いかも。

animals.compactMap { $0 as? Runable }.forEach {
    print("\($0.name)は走れます!")
    $0.run()
}

引数の変更を参照元に反映させたい時はinoutが使える

いわゆる参照渡しです。

Before

var fuga = "fugaです。"

print(fuga) // fugaです。

func addHogeString() {
    fuga.append("\n")
    fuga.append("hogeを追加します")
}

addHogeString() 

print(fuga) // fugaです。\n hogeを追加します。

After

func addHogeString(strings: inout String) {
    strings.append("\n")
    strings.append("hogeを追加します")
}

var fuga = "fugaです。"

print(fuga) // fugaです。

addHogeString(strings: &fuga)

print(fuga) // fugaです。\n hogeを追加します。

この&を使った参照渡しの表現はCombineのassignでも使いますね。

filter + first or lastfirst(where: ) or last(where: )で書ける。

Before

animals.filter { !($0 is Runable) }.first // Penguin

After

animals.first { !($0 is Runable) } // Punguin

firstを使う方がパフォーマンスにも優れるとかなんとか。

StackViewを使うと制約をグッと減らせる。

こんなViewを用意したいとします。(枠が見えやすいように色を変えています。)

Before

class ViewController: UIViewController {

    private lazy var hogeLabel: UILabel = {
        let label = UILabel()
        label.text = "hogehoge"
        return label
    }()

    private lazy var fugaLabel: UILabel = {
        let label = UILabel()
        label.text = "fugafuga"
        return label
    }()

    private lazy var moguLabel: UILabel = {
        let label = UILabel()
        label.text = "mogumogu"
        return label
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        setupLayout()
    }

    private func setupLayout() {
        [hogeLabel, fugaLabel, moguLabel].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            $0.backgroundColor = .gray
            view.addSubview($0)
        }

        NSLayoutConstraint.activate([
            hogeLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: 80),
            hogeLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -16),
            hogeLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 16),

            fugaLabel.topAnchor.constraint(equalTo: hogeLabel.bottomAnchor, constant: 16),
            fugaLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 16),
            fugaLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -16),

            moguLabel.topAnchor.constraint(equalTo: fugaLabel.bottomAnchor, constant: 16),
            moguLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 16),
            moguLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -16),
        ])
    }
}

After

class ViewController: UIViewController {

...

    private lazy var stackView: UIStackView = {
        let stackView = UIStackView()
        stackView.spacing = 16
        stackView.axis = .vertical
        return stackView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        setupLayout()
    }

    private func setupLayout() {
        view.backgroundColor = .lightGray

        stackView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(stackView)

        [hogeLabel, fugaLabel, moguLabel].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            $0.backgroundColor = .gray
            stackView.addArrangedSubview($0)
        }

        NSLayoutConstraint.activate([
            hogeLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: 80),
            hogeLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -16),
            hogeLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 16),
        ])
    }
}

StackViewすこ。使いすぎるとパフォーマンス落ちるとの声もあるので注意。

StackView + ScrollViewでお手軽に拡張しやすいスクロール可能なレイアウトを構築できる。

こんな感じにスクロールしたい画面を作る時はUIScrollVieを使いますが、ScrollViewはその中身のViewのサイズによってスクロールが発生するかどうかが決まるので中身をViewを用意してあげないといけません。そのViewをStackViewにしてやれば、stackViewにaddArreangedSubViewするだけでサイズが変わってくれるので、Viewの高さ計算とかしなくても楽にスクロールが発生します。

  • ScrollViewをSuperViewの全方向に0pxで制約を貼る
  • ScrollViewにStackViewをaddSubView
  • StackViewとFrameLayoutGuideを同じ横幅にする
  • StackViewをContentLayoutGuideの上下左右に0pxで制約を貼る

後はstackViewにViewを追加していくだけでStackViewのサイズが画面サイズより大きくなった時にスクロールが発生します。

スクロールなし スクロールあり

見えているViewの裏のViewのタッチイベントを取得したい時はhittestが使える

こういうScrollViewが重なった画面があるとします。


画像は今開発中の岩の位置を記録するアプリの画面です。

https://github.com/kawano108/RockMap

Before

そのままだと前面の縦ScrollViewがタッチイベントを吸収し、裏の横ScrollViewが反応しません。

After

そこでhittestです。
こいつでnilを返すようにoverrideすればタッチイベントを無視できます。

final class HeaderIgnorableScrollView: UIScrollView {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let view = super.hitTest(point, with: event)

        if view == self,
           point.x < UIScreen.main.bounds.width && point.y < UIScreen.main.bounds.height * (9/16) {
            return nil
        }
        return view
    }
}

裏面の横ScrollViewもタッチできるようになりました。

感動しました。

同じ処理をアノテーション化して省略する時はPropertyWrapperが使える

例えばKeychainへのsetとgetの処理。

Before

こんな感じでgetとsetをしてたとします。

final class KeychainManager {

    /// キー
    struct Key {
        static let accessToken = "accessToken"
    }

    /// キーチェーンインスタンス
    static let keychain = Keychain(service: Bundle.main.bundleIdentifier ?? "")

    static var accessToken: String {
        get { get(key: Key.accessToken) ?? "" }
        set { set(key: Key.accessToken, value: newValue) }
    }

    private static func get<T>(key: String) -> T? {
        do {
            return try KeychainManager.keychain.get(key) as? T

        } catch {
            print(error.localizedDescription)
            assertionFailure("Keychainからのデータの取得に失敗しました。")
            return nil
        }
    }

    private static func set(key: String, value: String) {
        do {
            return try KeychainManager.keychain.set(value, key: key)

        } catch {
            print(error.localizedDescription)
            assertionFailure("Keychainへのデータの保存に失敗しました。。")
        }
    }
}

// 利用時
KeychainManager.accessToken = accessToken // set
let accessToken = KeychainManager.accessToken

After

propertyWrapperを使えばsetとかgetを書かなくてよくなります。

import KeychainAccess

@propertyWrapper
class KeychainStorage<T: LosslessStringConvertible> {

    private let key: String

    var keychain: Keychain {
        guard let identifier = Bundle.main.object(forInfoDictionaryKey: UUID().uuidString) as? String else {
            return Keychain(service: "")
        }
        return Keychain(service: identifier)
    }

    init(key: String) {
        self.key = key
    }

    var wrappedValue: T? {
        get {
            do {
                guard let result = try keychain.get(key) else { return nil }
                return T(result)
            } catch {
                print(error.localizedDescription)
                return nil
            }
        }
        set {
            do {
                guard let new = newValue else {
                    try keychain.remove(key)
                    return
                }
                try keychain.set(String(new), key: key)
            } catch {
                print(error.localizedDescription)
            }
        }
    }
}

final class KeychainDataHolder {

    private enum Key: String {
        case uid = "_accessToken"
    }

    static let shared: KeychainDataHolder = KeychainDataHolder()

    private init() {}

    @KeychainStorage(key: Key.accessToken.rawValue)
    var accessToken: String?

}

// 利用時
KeychainDataHolder.shared.accessToken = accessToken // set
let accessToken = KeychainDataHolder.shared.accessToken //get

keyとプロパティを増やせばsetとgetは書かなくてよくなりますね。

終わりに

読んでいただいてありがとうございました!
もっといっぱいあると思うんですけどいざ出そうとすると出てこないんですよね。
明日は@takashicoさんです!

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

エラーメッセージ Invalid redeclaration of '***' について

開発環境

Swift5 Xcode12.2

概要

Invalid redeclaration of '***'
というエラーメッセージが出てきた際の対処法について記載します。

このエラーメッセージについて

この英語で書かれたエラーメッセージを読んでいきます。今回は比較的短い文章なんですが、
invalid(無効な), redeclaration(再宣言), of~(~の)
となっているのでこれらを繋げると、of以下のクラスやメソッドに対して無効な再宣言がある、ということになります。つまり、
'***' についての宣言が二回されているため、これを1つにする必要があります。

ソースコード

var abc = 0
var abc = 1 //エラー

対処方法

var abc = 0

というように、宣言を1つにする。どこかで同じ変数、同じメソッドに対して宣言が2度されてないかを確認する。

参考にした記事

Swiftのコンパイルエラー寄せ集め

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

[iOS]コードから追加したUI部品がSafe Area内に収まるようにするには

やり方

iOS11より前ではtopLayoutGuidebottomLayoutGuideを組み合わせて使う。
iOS11以降だとSafeAreaLayoutGuideに一本化されたのでそれを使う。

ExampleViewController.swift
private let greenView = UIView()

private func setupView() {
  greenView.translatesAutoresizingMaskIntoConstraints = false
  greenView.backgroundColor = .green
  view.addSubview(greenView)

  let margins = view.layoutMarginsGuide
  NSLayoutConstraint.activate([
    greenView.leadingAnchor.constraint(equalTo: margins.leadingAnchor),
    greenView.trailingAnchor.constraint(equalTo: margins.trailingAnchor)
    ])

  if #available(iOS 11, *) {
    let guide = view.safeAreaLayoutGuide
    NSLayoutConstraint.activate([
     greenView.topAnchor.constraintEqualToSystemSpacingBelow(guide.topAnchor, multiplier: 1.0),
     guide.bottomAnchor.constraintEqualToSystemSpacingBelow(greenView.bottomAnchor, multiplier: 1.0)
     ])
  } else {
     let standardSpacing: CGFloat = 8.0
     NSLayoutConstraint.activate([
     greenView.topAnchor.constraint(equalTo: topLayoutGuide.bottomAnchor, constant: standardSpacing),
     bottomLayoutGuide.topAnchor.constraint(equalTo: greenView.bottomAnchor, constant: standardSpacing)
     ])
  }

もしAutoLayoutを使っていない場合は、safeAreaの部分のマージンの値をsafeAreaInsets, iOS11未満であればtopLayoutGuide.length, bottomLayoutGuide.lengthより取得することもできる。

参考

iPhone X対応 ~Safe Areaの外側~
Positioning Content Relative to the Safe Area
USE YOUR LOAF - Safe Area Layout Guide
Stack Overflow - How do I use Safe Area Layout programmatically?

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

ViewとLayerで立方体をデッサンしてみる

昨年の球体デッサンに続き今年は立方体をデッサンしてみた。
今回は写真や画像を一切使わない正真正銘完全コーディングによるデッサンに拘った。(前回は一部写真を歪めてはめ込むチート技を使ってしまった)

結果から貼るとこうなった。
image.png

木の角材に見えるかな?
主に CoreAnimation CoreGraphics CoreImage を利用している。

プロジェクトは以下にアップした。
https://github.com/yumemi-ajike/Cube

  • Xcode 12.2
  • Swift 5.1

立方体の面

前面、背面、上面、底面、左側面、右側面のそれぞれをCALayerとして定義、正方形で追加する。
サイズは適当に一辺200とした。

WireCubeView.swift
    let size: CGFloat = 200
    lazy var frontLayer: CALayer = {
        let transform = CATransform3DMakeTranslation(0, 0, size / 2)
        return createFaceLayer(with: transform)
    }()
    lazy var rightLayer: CALayer = {
        var transform = CATransform3DMakeTranslation(size / 2, 0, 0)
        transform = CATransform3DRotate(transform, CGFloat.pi / 2 , 0, 1, 0)
        return createFaceLayer(with: transform)
    }()
    lazy var topLayer: CALayer = {
        var transform = CATransform3DMakeTranslation(0, -size / 2, 0)
        transform = CATransform3DRotate(transform, CGFloat.pi / 2 , 1, 0, 0)
        return createFaceLayer(with: transform)
    }()
    lazy var leftLayer: CALayer = {
        var transform = CATransform3DMakeTranslation(-size / 2, 0, 0)
        transform = CATransform3DRotate(transform, -CGFloat.pi / 2 , 0, 1, 0)
        return createFaceLayer(with: transform)
    }()
    lazy var bottomLayer: CALayer = {
        var transform = CATransform3DMakeTranslation(0, size / 2, 0)
        transform = CATransform3DRotate(transform, -CGFloat.pi / 2 , 1, 0, 0)
        return createFaceLayer(with: transform)
    }()
    lazy var backLayer: CALayer = {
        var transform = CATransform3DMakeTranslation(0, 0, -size / 2)
        transform = CATransform3DRotate(transform, CGFloat.pi , 0, 1, 0)
        return createFaceLayer(with: transform)
    }()

    func createFaceLayer(with transform: CATransform3D) -> CALayer {

        let layer = CALayer()
        layer.frame = CGRect(x: -size / 2, y: -size / 2, width: size, height: size)
        layer.borderColor = UIColor.white.cgColor
        layer.borderWidth = 1
        layer.transform = transform
        layer.allowsEdgeAntialiasing = true
        return layer
    }
WireCubeView.swift
    override func layoutSubviews() {
        super.layoutSubviews()
        ...
        baseLayer.addSublayer(frontLayer)
        baseLayer.addSublayer(rightLayer)
        baseLayer.addSublayer(topLayer)
        baseLayer.addSublayer(leftLayer)
        baseLayer.addSublayer(bottomLayer)
        baseLayer.addSublayer(backLayer)
        baseLayer.position = CGPoint(x: bounds.midX, y: bounds.midY)

        ...
        layer.addSublayer(baseLayer)
        ...
    }

前面はZ方向に100移動。

        let transform = CATransform3DMakeTranslation(0, 0, size / 2)

右側面はX方向に100移動、Y軸を CGFloat.pi / 2 つまり90°回転させる。

        var transform = CATransform3DMakeTranslation(size / 2, 0, 0)
        transform = CATransform3DRotate(transform, CGFloat.pi / 2 , 0, 1, 0)

上面はY方向に-100移動、X軸を90°回転させる。

        var transform = CATransform3DMakeTranslation(0, -size / 2, 0)
        transform = CATransform3DRotate(transform, CGFloat.pi / 2 , 1, 0, 0)

左側面は右側面とは逆にX方向に-100移動、Y軸を-90°回転させる。

        var transform = CATransform3DMakeTranslation(-size / 2, 0, 0)
        transform = CATransform3DRotate(transform, -CGFloat.pi / 2 , 0, 1, 0)

底面は上面とは逆にY方向に100移動、X軸を-90°回転させる。

        var transform = CATransform3DMakeTranslation(0, size / 2, 0)
        transform = CATransform3DRotate(transform, -CGFloat.pi / 2 , 1, 0, 0)

背面はZ方向に-100移動、Y軸を CGFloat.pi つまり180°回転させる。

        var transform = CATransform3DMakeTranslation(0, 0, -size / 2)
        transform = CATransform3DRotate(transform, CGFloat.pi , 0, 1, 0)

この時点でビルド実行すると前面(=frontLayer)しか見えていない状態となる。
figure1.png

立方体として認識できる見栄えにする必要があるため、全体の角度を調整する。

        override func layoutSubviews() {
        ...
        var transform = CATransform3DIdentity
        ...
        transform = CATransform3DRotate(transform, -30 * CGFloat.pi / 180, 0, 1, 0)
        transform = CATransform3DRotate(transform, -30 * CGFloat.pi / 180, 1, 0, 0)
        transform = CATransform3DRotate(transform, 15 * CGFloat.pi / 180, 0, 0, 1)
        baseLayer.transform = transform

6面全面の親である、baseLayerにtransformをかける。
Y軸を−30°、X軸を-30°、Z軸を15°傾けるとなんとか良い感じに立方体に見えなくもない。
因みに傾けると線が微妙な角度で描画されジャギって汚くなるため各レイヤーにはアンチエイリアスをかけておく。

layer.allowsEdgeAntialiasing = true

figure2.png

このままだとパースペクティブが効いていないため奥行きが感じられずどっち方向にあるのか判別できない。

        transform.m34 = -1.0 / 1000

CATransform3D.m34 に値を入れることでパースがかかりどういう方向に存在しているのかわかるようになる。値は適当。
figure3.png

しっかり立方体に見えるようになった。
各面の見えるバランスも良さげ。

面を作る

グラデーションで面の微妙な明暗を表現する。

見えている前面、上面、右面を CAGradientLayer にして色を付ける。

CubeView.swift
    lazy var frontLayer: CAGradientLayer = {
        let transform = CATransform3DMakeTranslation(0, 0, size / 2)
        return createGradientFaceLayer(with: transform,
                                       colors: [UIColor(white: 0.4, alpha: 1.0),
                                                UIColor(white: 0.6, alpha: 1.0)])
    }()
    lazy var rightLayer: CAGradientLayer = {
        var transform = CATransform3DMakeTranslation(size / 2, 0, 0)
        transform = CATransform3DRotate(transform, CGFloat.pi / 2 , 0, 1, 0)
        return createGradientFaceLayer(with: transform,
                                       colors: [UIColor(white: 0.6, alpha: 1.0),
                                                UIColor(white: 0.8, alpha: 1.0)])
    }()
    lazy var topLayer: CAGradientLayer = {
        var transform = CATransform3DMakeTranslation(0, -size / 2, 0)
        transform = CATransform3DRotate(transform, CGFloat.pi / 2 , 1, 0, 0)
        return createGradientFaceLayer(with: transform,
                                       colors: [UIColor(white: 1.0, alpha: 1.0),
                                                UIColor(white: 0.8, alpha: 1.0)])
    }()
CubeView.swift
    func createGradientFaceLayer(with transform: CATransform3D, colors: [UIColor]) -> CAGradientLayer {

        let layer = CAGradientLayer()
        layer.frame = CGRect(x: -size / 2, y: -size / 2, width: size, height: size)
        layer.colors = colors.map { $0.cgColor }
        layer.transform = transform
        layer.allowsEdgeAntialiasing = true
        return layer
    }

光源はやや右奥上にある想定で
前面は上から下まで40%→60%、
右面は上から下まで60%→80%、
上面は奥から手前まで100%→80%の白地にグレーのグラデーションがかかるようにしている。
figure4.png
前面と右面を下方向に明るくしているのは接地面の反射光が当たり明るくなっているという表現。

背景/地面を作る

CubeView の後ろに GroundView を追加する。
背景/地面(=GroundView)は立方体(=CubeView)とは別の階層として定義し、立方体そのものの再利用性を高めている。

GroundView.swift
final class GroundView: UIView {

    lazy var groundLayer: CAGradientLayer = {
        let layer = CAGradientLayer()
        layer.colors = [UIColor(white: 1.0, alpha: 1.0).cgColor,
                        UIColor(white: 0.7, alpha: 1.0).cgColor]
        layer.locations = [0.5, 1.0]
        return layer
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        layer.addSublayer(groundLayer)
    }

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

    override func layoutSubviews() {
        super.layoutSubviews()
        groundLayer.frame = bounds
    }
}

figure5.png

影を落とす

背景/地面ができたので立方体の影を落とす。
影は背景/地面 or 立方体どちらのView階層に存在すべきか議論が分かれそうだが、
影も立方体と同じパース具合にしたいのでtransformの記述が1箇所で済みそうなCubeViewのbaseLayerに追加することにした。

どうやって影を表現するか試行錯誤したけど、立方体の各面から影を作り出すことが難しかったので結局影としての面を全く別に設けることにした。

影のベースレイヤーを手前に広がるように追加する。

CubeView.swift
    lazy var shadowLayer: CALayer = {
        var transform = CATransform3DMakeTranslation(size / 2, size / 2, size / 2)
        transform = CATransform3DRotate(transform, CGFloat.pi / 2 , 1, 0, 0)
        let layer = CALayer()
        layer.frame = CGRect(x: -size, y: -size, width: size * 2, height: size * 2)
        layer.transform = transform
        layer.allowsEdgeAntialiasing = true
        return layer
    }()
CubeView.swift
    override func layoutSubviews() {
        super.layoutSubviews()
        ...
        baseLayer.addSublayer(shadowLayer)
        ...
    }

figure6.png

影となるグラデーションを落とす。

CubeView.swift
    lazy var shadowGradientLayer: CAGradientLayer = {
        let layer = CAGradientLayer()
        layer.frame = CGRect(x: 0, y: 0, width: size * 2, height: size * 2)
        layer.colors = [UIColor(white: 0, alpha: 0.4), .clear].map { $0.cgColor }
        layer.allowsEdgeAntialiasing = true
        return layer
    }()
CubeView.swift
    override func layoutSubviews() {
        super.layoutSubviews()

        shadowLayer.addSublayer(shadowGradientLayer)
        ...
    }

figure7.png

影の形をパスでクリップする。

CubeView.swift
    lazy var shadowShapeLayer: CAShapeLayer = {
        let layer = CAShapeLayer()
        layer.frame = CGRect(x: 0, y: 0, width: size * 2, height: size * 2)
        layer.fillColor = UIColor.black.cgColor
        let path = CGMutablePath()
        path.move(to: CGPoint(x: 0, y: size))
        path.addLine(to: CGPoint(x: size, y: size))
        path.addLine(to: CGPoint(x: size, y: 0))
        path.addLine(to: CGPoint(x: size * 1.5, y: size))
        path.addLine(to: CGPoint(x: size / 2 * 3, y: size * 2))
        path.addLine(to: CGPoint(x: size / 2, y: size * 2))
        path.addLine(to: CGPoint(x: 0, y: size))
        path.closeSubpath()
        layer.path = path
        layer.allowsEdgeAntialiasing = true
        return layer
    }()
CubeView.swift
    override func layoutSubviews() {
        super.layoutSubviews()

        ...
        shadowGradientLayer.mask = shadowShapeLayer
        ...

figure9.png

グラデーションに対してこのようなパスでクリップすることで影にみせかける。
figure8.png

それらしくなっているが何の立方体かわからないし、リアルさにかけるためCG丸出し感が否めないので詳細を作り込んでいく。

形状の作り込み

立方体の形状などディティールを追求していくことで画としての説得力を上げていく。

角を落とす

各面同士が直角に接している部分が現実の物体ではありえないほど鋭い。
この時点ではまだ素材は明らかになっていないが現実のものなら金属でも木材でもプラスティックでも必ずわずかな角の丸みがあるはずなので角を落としていく。

角を丸めて内容をクリップしたいため CAGradientLayerCALayer のsublayerにして、cornerRadius を設定する。

    let cornerRadius: CGFloat = 2.0
    ...

    func createGradientFaceLayer(with transform: CATransform3D, colors: [UIColor]) -> CALayer {

        let layer = CALayer()
        let gradientLayer = CAGradientLayer()
        gradientLayer.frame = CGRect(x: 0, y: 0, width: size, height: size)
        gradientLayer.colors = colors.map { $0.cgColor }
        layer.frame = CGRect(x: -size / 2, y: -size / 2, width: size, height: size)
        layer.cornerRadius = cornerRadius
        layer.masksToBounds = true
        layer.transform = transform
        layer.allowsEdgeAntialiasing = true
        layer.addSublayer(gradientLayer)
        return layer
    }

次に前面/上面/右側のそれぞれが接する部分にハイライトとして白→透明のグラデーションを置く。

    lazy var frontTopLayer: CAGradientLayer = {
        let layer = CAGradientLayer()
        layer.frame = CGRect(x: -size / 2, y: -size / 2, width: size, height: cornerRadius)
        layer.colors = [UIColor(white: 1, alpha: 0.8),
                        UIColor(white: 1, alpha: 0)].map { $0.cgColor }
        layer.transform = CATransform3DMakeTranslation(size / 2, size / 2, 0)
        layer.allowsEdgeAntialiasing = true
        return layer
    }()
    lazy var frontLeftLayer: CAGradientLayer = {
        let layer = CAGradientLayer()
        let transform = CATransform3DMakeTranslation(size / 2, size / 2, 0)
        layer.frame = CGRect(x: -size / 2, y: -size / 2, width: cornerRadius, height: size)
        layer.colors = [UIColor(white: 1, alpha: 0.3),
                        UIColor(white: 1, alpha: 0)].map { $0.cgColor }
        layer.startPoint = CGPoint(x: 0, y: 0.5)
        layer.endPoint = CGPoint(x: 1, y: 0.5)
        layer.transform = transform
        layer.allowsEdgeAntialiasing = true
        return layer
    }()
    lazy var frontRightLayer: CAGradientLayer = {
        let layer = CAGradientLayer()
        let transform = CATransform3DMakeTranslation(size * 1.5 - cornerRadius, size / 2, 0)
        layer.frame = CGRect(x: -size / 2, y: -size / 2, width: cornerRadius, height: size)
        layer.colors = [UIColor(white: 1, alpha: 0),
                        UIColor(white: 1, alpha: 0.3)].map { $0.cgColor }
        layer.startPoint = CGPoint(x: 0, y: 0.5)
        layer.endPoint = CGPoint(x: 1, y: 0.5)
        layer.transform = transform
        layer.allowsEdgeAntialiasing = true
        return layer
    }()
    lazy var frontBottomLayer: CAGradientLayer = {
        let layer = CAGradientLayer()
        layer.frame = CGRect(x: -size / 2, y: -size / 2, width: size, height: cornerRadius)
        layer.colors = [UIColor(white: 1, alpha: 0),
                        UIColor(white: 1, alpha: 0.2)].map { $0.cgColor }
        layer.transform = CATransform3DMakeTranslation(size / 2, size * 1.5 - cornerRadius, 0)
        layer.allowsEdgeAntialiasing = true
        return layer
    }()
    override func layoutSubviews() {
        ...
        frontLayer.addSublayer(frontTopLayer)
        frontLayer.addSublayer(frontRightLayer)
        frontLayer.addSublayer(frontLeftLayer)
        frontLayer.addSublayer(frontBottomLayer)
        rightLayer.addSublayer(rightTopLayer)
        rightLayer.addSublayer(rightLeftLayer)
        rightLayer.addSublayer(rightRightLayer)
        rightLayer.addSublayer(rightBottomLayer)
        topLayer.addSublayer(topTopLayer)
        topLayer.addSublayer(topBottomLayer)
        topLayer.addSublayer(topRightLayer)
        topLayer.addSublayer(topLeftLayer)
        ...
    }

cornerRadiusを大袈裟な値にするとこんな感じでサイコロのようになる。
figure10.png

立方体の形状と影が合わないため、立方体に合った影の形に shadowShapeLayer のパスを修正する。

    lazy var shadowShapeLayer: CAShapeLayer = {
        let layer = CAShapeLayer()
        layer.frame = CGRect(x: 0, y: 0, width: size * 2, height: size * 2)
        layer.fillColor = UIColor.black.cgColor
        let path = CGMutablePath()
        path.move(to: CGPoint(x: cornerRadius, y: size))
        path.addLine(to: CGPoint(x: size - cornerRadius, y: size))
        // curve
        path.addCurve(to: CGPoint(x: size, y: size - cornerRadius), control1: CGPoint(x: size, y: size), control2: CGPoint(x: size, y: size - cornerRadius))
        path.addLine(to: CGPoint(x: size, y: cornerRadius))
        // curve
        path.addCurve(to: CGPoint(x: size + cornerRadius, y: cornerRadius * 2), control1: CGPoint(x: size, y: 0), control2: CGPoint(x: size + cornerRadius, y: cornerRadius * 2))
        path.addLine(to: CGPoint(x: size * 1.5, y: size))
        path.addLine(to: CGPoint(x: size / 2 * 3, y: size * 2))
        path.addLine(to: CGPoint(x: size / 2, y: size * 2))
        path.addLine(to: CGPoint(x: cornerRadius, y: size + cornerRadius * 2))
        // curve
        path.addCurve(to: CGPoint(x: cornerRadius, y: size), control1: CGPoint(x: 0, y: size), control2: CGPoint(x: cornerRadius, y: size))
        path.closeSubpath()
        layer.path = path
        layer.allowsEdgeAntialiasing = true
        return layer
    }()

figure11.png

角丸の値を小さくして目立たなくする。
figure12.png

接地面の影

立方体の接地面の影をしっかり描くことで物体がある面に接地していることをより表現できる上に、画にパシッと締りができる。

立方体の底面である bottomLayer にshadowを設定することで実現してみる。。。
figure13.png

一見、おかしくないようだがよく見ると画面左端の影の落ち方がおかしい。
また角を丸めているはずなのに影が濃すぎるという問題も浮かび上がる。
figure13-1.png

bottomLayer のsublayerに影の対象となる面 shadowBaseLayer を追加し背景色をつけることでbottomLayer にshadowを設定した際、この shadowBaseLayer に対してのみ影がかかるようになる。

    lazy var bottomLayer: CALayer = {
        var transform = CATransform3DMakeTranslation(0, size / 2, 0)
        transform = CATransform3DRotate(transform, -CGFloat.pi / 2 , 1, 0, 0)
        let layer = createFaceLayer(with: transform, color: .clear)
        let shadowRadius: CGFloat = 3
        let shadowBaseLayer = CALayer()
        shadowBaseLayer.frame = CGRect(x: cornerRadius, y: cornerRadius, width: size - cornerRadius * 2, height: size - cornerRadius * 2)
        shadowBaseLayer.backgroundColor = UIColor.white.cgColor
        layer.addSublayer(shadowBaseLayer)
        layer.shadowColor = UIColor.black.cgColor
        layer.shadowRadius = shadowRadius
        layer.shadowOpacity = 1
        layer.shadowOffset = CGSize(width: 1, height: -2)
        return layer
    }()

shadowBaseLayerbottomLayer の矩形に対して角丸分だけ内側にレイアウトされるようにすることで底面の周囲が接地していない表現を行う。台に置かれたサイコロの周囲がぴったり接地していないのと同じ。
figure14.png
figure15-2.png

一旦、作り込み前後の比較

ハイライト/影なし ハイライト/影あり
figure15-1.png figure15-2.png

画のリアル感はやや向上した。形状や状態がより伝わりやすくもなったが、如何せん何でできているのか素材や質感が全くわからないのでデッサンとしてはNGもう一歩。

素材や質感

立方体の素材を描くことによって質感を表現し、さらに説得力を上げていく。

ガラス製だと立方体が置かれている周りの風景の映り込みを表現する必要があり、これをコードのみで表現するのは難しそう。また前回のように写真を使ってしまいそうだ。
プラスティック製も表面がツルツルしているため、映り込みの表現が必要だしなんか味気ない。
金属製も同じく。
石膏は表面についた傷や汚れなどを表現する必要があり、これもコードで表現するのは過酷。

木ならどうか。
年輪はある程度規則性があるので表現できるかもしれないということで角材の木目を描いてみることにした。

準備

まずは各面にテクスチャを貼る準備。
各面のsublayerに加えたグラデーションの色指定をグレースケールから白のアルファでの表現に変更して、
各面のCALayer.contentsに CGImage を入れられるようにする。

CubeView.swift
    lazy var frontLayer: CALayer = {
        ...
        return createGradientFaceLayer(with: transform,
                                       colors: [UIColor(white: 0, alpha: 0.6),
                                                UIColor(white: 0, alpha: 0.4)])
    }()

テクスチャの作成

LumberTexture クラスを追加。
木の色や年輪の間隔、幅、濃さ、色を管理し、最終的にCGImageを吐くオブジェクト。
吐いたImageを各レイヤーにセットする寸法。

設計的には
立方体の角度や陰影は CubeView が管理していて、LumberTexture はテクスチャ情報のみを管理することになるので本来は6面すべてのイメージを生成するオブジェクトでなければならないが、時間の都合で見えている3面だけにした。

LumberTexture.swift
final class LumberTexture {
    // 年輪
    struct Ring {
        let distance: CGFloat          // 間隔
        let width: CGFloat             // 幅
        let depth: CGFloat             // 濃さ
        let colorComponents: [CGFloat] // 色
    }
    // 1辺の長さ
    let side: CGFloat
    // 木の色
    let baseColorComponents: [CGFloat] = [(226 / 255), (193 / 255), (146 / 255)]
    // 細かい年輪の色 RGB
    let smoothRingColorComponents: [CGFloat] = [(199 / 255), (173 / 255), (122 / 255)]
    // 荒い年輪の色 RGB
    let roughRingColorComponents: [[CGFloat]] = [
        [(176 / 255), (106 / 255), (71 / 255)],
        [(194 / 255), (158 / 255), (96 / 255)],
    ]
    // 細かい年輪の情報
    private var roughRings: [Ring] = []
    // 荒い年輪の情報
    private var smoothRings: [Ring] = []
}

年輪の情報生成

細かい年輪は狭い感覚/細い幅で対角線の長さ分だけランダムに用意する。
色は木の色に対してあまり目立たない色を指定している。
色は適当にググって出てきた檜材からサンプリングしたRGB。

LumberTexture.swift
    private func createSmoothRings() -> [Ring] {

        var smoothRings: [Ring] = []
        var pointer: CGFloat = 0

        repeat {
            let distance = CGFloat(Float.random(in: 2 ... 3))
            let width = CGFloat(Float.random(in: 0.5 ... 2))
            let depth = CGFloat(Float.random(in: 0.8 ... 1.0))
            let colorComponents = smoothRingColorComponents
            if (pointer + distance + width / 2) < (side * sqrt(2)) {
                smoothRings.append(Ring(distance: distance, width: width, depth: depth, colorComponents: colorComponents))
                pointer += distance
            } else {
                break
            }

        } while(pointer < (side * sqrt(2)))

        return smoothRings
    }

荒い年輪は広い間隔/太い幅も入るようにして木の色に対して目立つ色を指定している。

LumberTexture.swift
    private func createRoughRings() -> [Ring] {

        var roughRings: [Ring] = []
        var pointer: CGFloat = 0

        repeat {
            let distance = CGFloat(Float.random(in: 5 ... 30))
            let width = CGFloat(Float.random(in: 2 ... 12))
            let depth = CGFloat(Float.random(in: 0.4 ... 0.6))
            let colorComponents = roughRingColorComponents[Int.random(in: 0 ... 1)]
            if (pointer + distance + width / 2) < (side * sqrt(2)) {
                roughRings.append(Ring(distance: distance, width: width, depth: depth, colorComponents: colorComponents))
                pointer += distance
            } else {
                break
            }

        } while(pointer < (side * sqrt(2)))

        return roughRings
    }

上面と側面が別のイメージになるがそれぞれ同じ年輪幅でないと辻褄が合わなくなってくるため年輪の情報を保持している。
イメージの取得funcを用意してこんな感じでパスで年輪を描く。

上面のイメージ

LumberTexture.swift
func lumberTopImage() -> CGImage? {

        UIGraphicsBeginImageContext(CGSize(width: side, height: side))
        guard let context = UIGraphicsGetCurrentContext() else { return nil }

        // Draw base color
        context.setFillColor(UIColor(red: baseColorComponents[0],
                                     green: baseColorComponents[1],
                                     blue: baseColorComponents[2],
                                     alpha: 1).cgColor)
        context.fill(CGRect(x: 0, y: 0, width: side, height: side))

        // Draw annual tree rings
        [smoothRings, roughRings].forEach { rings in
            var pointer: CGFloat = 0
            rings.forEach { ring in
                pointer += ring.distance

                context.setLineWidth(ring.width)
                let startPoint = CGPoint(x: pointer, y: side)
                let endPoint = CGPoint(x: 0, y: side - pointer)
                context.move(to: startPoint)
                context.addCurve(to: endPoint,
                                  control1: CGPoint(x: pointer, y: side - pointer),
                                  control2: endPoint)
                let components: [CGFloat] = ring.colorComponents
                context.setStrokeColor(UIColor(red: components[0],
                                               green: components[1],
                                               blue: components[2],
                                               alpha: ring.depth).cgColor)
                context.strokePath()
            }
        }

        return context.makeImage()
    }

ランダムで生成しているので毎回異なる模様になるけど大体上面のイメージはこんな感じになる。
バームクーヘン。
top_image.png

前面のイメージ

前面にくる木目は上面のこの部分を引き伸ばしたものが垂直に落ちる形になる。
figure17-1.png

保持している年輪の情報から前面の木目を描く。

LumberTexture.swift
    func lumberSideImage() -> CGImage? {

        UIGraphicsBeginImageContext(CGSize(width: side * sqrt(2), height: side))
        guard let context = UIGraphicsGetCurrentContext() else { return nil }

        // Draw base color
        context.setFillColor(UIColor(red: baseColorComponents[0],
                                     green: baseColorComponents[1],
                                     blue: baseColorComponents[2],
                                     alpha: 1).cgColor)
        context.fill(CGRect(x: 0, y: 0, width: side * sqrt(2), height: side))

        // Draw smooth annual tree rings
        var pointer: CGFloat = 0
        smoothRings.forEach { ring in
            pointer += ring.distance

            context.setLineWidth(ring.width)
            let startPoint = CGPoint(x: pointer, y: 0)
            let endPoint = CGPoint(x: pointer, y: side)
            context.move(to: startPoint)
            context.addLine(to: endPoint)
            let components: [CGFloat] = ring.colorComponents
            context.setStrokeColor(UIColor(red: components[0],
                                           green: components[1],
                                           blue: components[2],
                                           alpha: ring.depth).cgColor)
            context.strokePath()
        }

        // Draw rough annual tree rings
        pointer = 0
        roughRings.forEach { ring in
            pointer += ring.distance

            context.setLineWidth(ring.width)
            let startPoint = CGPoint(x: pointer, y: 0)
            let endPoint = CGPoint(x: pointer, y: side)
            context.move(to: startPoint)
            context.addLine(to: endPoint)
            let components: [CGFloat] = ring.colorComponents
            context.setStrokeColor(UIColor(red: components[0],
                                           green: components[1],
                                           blue: components[2],
                                           alpha: ring.depth).cgColor)
            context.strokePath()
        }

        ...
    }

上面と同じ年輪情報で、上から下に描画する。
figure17-2.png

但し、人工物のように全ての年輪が並行/垂直に描かれるのは不自然なため、
CIFilterCITwirlDistortion を使って少し歪める。

LumberTexture.swift
    func lumberSideImage() -> CGImage? {

        ...

        // Distort the pattern
        if let image = context.makeImage() {

            let ciimage = CIImage(cgImage: image)
            let filter = CIFilter(name: "CITwirlDistortion")
            filter?.setValue(ciimage, forKey: kCIInputImageKey)
            filter?.setValue(CIVector(x: side * 1.2, y: -side / 3), forKey: kCIInputCenterKey)
            filter?.setValue(side * 1.3, forKey: kCIInputRadiusKey)
            filter?.setValue(CGFloat.pi / 8, forKey: kCIInputAngleKey)

            if let outputImage = filter?.outputImage {
                let cicontext = CIContext(options: nil)
                return cicontext.createCGImage(outputImage, from: CGRect(x: 0, y: 0, width: side, height: side))
            }

            return image
        }
        return nil
    }

side_image.png

上面と前面にイメージをはめてみる。
figure17-3.png

右側面のイメージ

ここの表現が非常に難しい。。
上面と前面の年輪の断面を同時に表現する必要があってさらに前面には歪みも設けているため、全てパスで表現すると相当複雑な計算が必要になると思われる。
知識もないし工数もないのでなんとか試行錯誤して誤魔化すことにした。

まずは上面の断面である年輪を描く。
ここから続く模様を生成しなければならない。
figure18-1.png

上面イメージの右端1pxからタイリングして90°回転したイメージを作り出す。

LumberTexture.swift
    private func topTilingImage(topImage: CGImage) -> CGImage? {

        UIGraphicsBeginImageContext(CGSize(width: side, height: side))
        guard let context = UIGraphicsGetCurrentContext() else { return nil }

        if let cropImage = topImage.cropping(to: CGRect(x: side - 1, y: 0, width: 1, height: side)) {

            context.saveGState()
            context.rotate(by: -CGFloat.pi / 2)
            context.draw(cropImage, in: CGRect(x: 0, y: 0, width: side, height: side), byTiling: true)
            context.restoreGState()

            return context.makeImage()
        }
        return nil
    }

figure18-2.png

次に前面の断面である年輪を描く。
ここから続く模様を生成しなければならないが、歪めた影響でやや複雑。
figure18-3.png

こちらも同じくイメージの右端1pxからタイリングしてイメージを作り出す。

LumberTexture.swift
    private func sideTilingImage(sideImage: CGImage) -> CGImage? {

        UIGraphicsBeginImageContext(CGSize(width: side, height: side))
        guard let context = UIGraphicsGetCurrentContext() else { return nil }

        if let cropImage = sideImage.cropping(to: CGRect(x: side - 1, y: 0, width: 1, height: side)) {

            context.saveGState()
            context.draw(cropImage, in: CGRect(x: 0, y: 0, width: side, height: side), byTiling: true)
            context.restoreGState()
            return context.makeImage()
        }
        return nil
    }

figure18-4.png

上断面に合わせると 前断面に合わせると
figure18-5.png figure18-6.png
一見良さそうだが、前面の歪み部分と合ってこない。。 前面の歪みから続く部分はこちらで良さそうだが。。

3D的に考えると前断面は少しで、あとは基本 上断面模様になるはず?こんな感じ?やや不自然だがまあいいか。
stretch_image.png

上断面イメージにグラデーションマスクした前断面イメージを合成する。

LumberTexture.swift
    func lumberStrechImage(topImage: CGImage?, sideImage: CGImage?) -> CGImage? {

        UIGraphicsBeginImageContext(CGSize(width: side, height: side))
        guard let context = UIGraphicsGetCurrentContext() else { return nil }

        if let topImage = topImage,
           let tilingImage = topTilingImage(topImage: topImage) {

            context.saveGState()
            context.draw(tilingImage, in: CGRect(x: 0, y: 0, width: side, height: side))
            context.restoreGState()
        }

        if let sideImage = sideImage,
            let tilingImage = sideTilingImage(sideImage: sideImage),
            let maskedImage = gradientMaskedImage(image: tilingImage) {

            context.saveGState()
            context.draw(maskedImage, in: CGRect(x: 0, y: 0, width: side, height: side))
            context.restoreGState()
        }

        return context.makeImage()
    }
LumberTexture.swift
    private func gradientMaskedImage(image: CGImage) -> CGImage? {

        UIGraphicsBeginImageContext(CGSize(width: side, height: side))
        guard let context = UIGraphicsGetCurrentContext() else { return nil }

        if let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceGray(),
                                     colors: [UIColor.black.cgColor,
                                              UIColor.white.cgColor] as CFArray,
                                     locations: [0.8, 1.0]) {

            context.saveGState()
            context.drawLinearGradient(gradient,
                                       start: CGPoint(x: 0, y: 0),
                                       end: CGPoint(x: side / 4, y: side / 8),
                                       options: [.drawsBeforeStartLocation, .drawsAfterEndLocation])
            context.restoreGState()
            if let maskImage = context.makeImage(),
               let mask = CGImage(maskWidth: maskImage.width,
                                  height: maskImage.height,
                                  bitsPerComponent: maskImage.bitsPerComponent,
                                  bitsPerPixel: maskImage.bitsPerPixel,
                                  bytesPerRow: maskImage.bytesPerRow,
                                  provider: maskImage.dataProvider!,
                                  decode: nil,
                                  shouldInterpolate: false) {

                return image.masking(mask)
            }
        }
        return nil
    }

あまり自信ないけどこんな感じで誤魔化した。
figure18-7.png

木目の保存

木目の描画は毎回ランダムで別の幅や色太さになる。
気に入った木目を継続できるようにすれば作り込み時の比較にも一役買いそう。

Ring 構造体をCodableにする。

LumberTexture.swift
final class LumberTexture {
    struct Ring: Codable {
        let distance: CGFloat
        let width: CGFloat
        let depth: CGFloat
        let colorComponents: [CGFloat]
    }
    ...

年輪の情報を保持している部分に保存機能を付ける。
Ring をCodableにしたため、JSONDecoder/JSONEncoderを使ってそのままUserDefaultsに読み書きする。

LumberTexture.swift
    private func createSmoothRings() -> [Ring] {

        // Restore saved rings from UserDefaults
        if let data = UserDefaults.standard.data(forKey: "SmoothRings"),
              let rings = try? JSONDecoder().decode([Ring].self, from: data) {

            return rings
        }

        // 生成処理

        // Save rings to UserDefaults
        UserDefaults.standard.setValue(try? JSONEncoder().encode(smoothRings), forKeyPath: "SmoothRings")

        return smoothRings
    }

タップした時にのみ木目を作り直すようにする。

CubeView.swift
    override func layoutSubviews() {
        super.layoutSubviews()

        ...

        updateTexture()
        addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(updateTextureAction)))
    }
CubeView.swift
    private func updateTexture() {

        let topImage = texture.lumberTopImage()
        let sideImage = texture.lumberSideImage()
        topLayer.contents = topImage
        frontLayer.contents = sideImage
        rightLayer.contents = texture.lumberStrechImage(topImage: topImage, sideImage: sideImage)
    }

    @objc private func updateTextureAction() {

        texture.updateRings()
        updateTexture()
    }
LumberTexture.swift
final class LumberTexture {

    ...

    func updateRings() {

        UserDefaults.standard.removeObject(forKey: "SmoothRings")
        UserDefaults.standard.removeObject(forKey: "RoughRings")
        self.smoothRings = createSmoothRings()
        self.roughRings = createRoughRings()
    }
}

これでタップしない限り木目が更新されない形になる。

ハイライトの調整

figure19-0.png
上面に接する部分のハイライトが強すぎるため明度調整。

--- a/Cube/CubeView.swift
+++ b/Cube/CubeView.swift
@@ -65,7 +65,7 @@ final class CubeView: UIView {
     lazy var frontTopLayer: CAGradientLayer = {
         let layer = CAGradientLayer()
         layer.frame = CGRect(x: -size / 2, y: -size / 2, width: size, height: cornerRadius)
-        layer.colors = [UIColor(white: 1, alpha: 0.8),
+        layer.colors = [UIColor(white: 1, alpha: 0.3),
                         UIColor(white: 1, alpha: 0)].map { $0.cgColor }
         layer.transform = CATransform3DMakeTranslation(size / 2, size / 2, 0)
         layer.allowsEdgeAntialiasing = true
@@ -107,7 +107,7 @@ final class CubeView: UIView {
     lazy var rightTopLayer: CAGradientLayer = {
         let layer = CAGradientLayer()
         layer.frame = CGRect(x: -size / 2, y: -size / 2, width: size, height: cornerRadius)
-        layer.colors = [UIColor(white: 1, alpha: 0.8),
+        layer.colors = [UIColor(white: 1, alpha: 0.3),
                         UIColor(white: 1, alpha: 0)].map { $0.cgColor }
         layer.transform = CATransform3DMakeTranslation(size / 2, size / 2, 0)
         layer.allowsEdgeAntialiasing = true
@@ -172,7 +172,7 @@ final class CubeView: UIView {
         let transform = CATransform3DMakeTranslation(size * 1.5 - cornerRadius, size / 2, 0)
         layer.frame = CGRect(x: -size / 2, y: -size / 2, width: cornerRadius, height: size)
         layer.colors = [UIColor(white: 1, alpha: 0),
-                        UIColor(white: 1, alpha: 1)].map { $0.cgColor }
+                        UIColor(white: 1, alpha: 0.3)].map { $0.cgColor }
         layer.startPoint = CGPoint(x: 0, y: 0.5)
         layer.endPoint = CGPoint(x: 1, y: 0.5)
         layer.transform = transform
@@ -183,7 +183,7 @@ final class CubeView: UIView {
         let layer = CAGradientLayer()
         layer.frame = CGRect(x: -size / 2, y: -size / 2, width: size, height: cornerRadius)
         layer.colors = [UIColor(white: 1, alpha: 0),
-                        UIColor(white: 1, alpha: 1)].map { $0.cgColor }
+                        UIColor(white: 1, alpha: 0.3)].map { $0.cgColor }
         layer.transform = CATransform3DMakeTranslation(size / 2, size * 1.5 - cornerRadius, 0)
         layer.allowsEdgeAntialiasing = true
         return layer

figure19-1.png

グレースケールにして確認してみる。

grayscale.diff
diff --git a/Cube/LumberTexture.swift b/Cube/LumberTexture.swift
index a5cdbb4..1df3b2c 100644
--- a/Cube/LumberTexture.swift
+++ b/Cube/LumberTexture.swift
@@ -116,7 +116,7 @@ extension LumberTexture {
         context.setFillColor(UIColor(red: baseColorComponents[0],
                                      green: baseColorComponents[1],
                                      blue: baseColorComponents[2],
-                                     alpha: 1).cgColor)
+                                     alpha: 1).convertToGrayScaleColor().cgColor)
         context.fill(CGRect(x: 0, y: 0, width: side, height: side))

         // Draw annual tree rings
@@ -136,7 +136,7 @@ extension LumberTexture {
                 context.setStrokeColor(UIColor(red: components[0],
                                                green: components[1],
                                                blue: components[2],
-                                               alpha: ring.depth).cgColor)
+                                               alpha: ring.depth).convertToGrayScaleColor().cgColor)
                 context.strokePath()
             }
         }
@@ -153,7 +153,7 @@ extension LumberTexture {
         context.setFillColor(UIColor(red: baseColorComponents[0],
                                      green: baseColorComponents[1],
                                      blue: baseColorComponents[2],
-                                     alpha: 1).cgColor)
+                                     alpha: 1).convertToGrayScaleColor().cgColor)
         context.fill(CGRect(x: 0, y: 0, width: side * sqrt(2), height: side))

         // Draw smooth annual tree rings
@@ -170,7 +170,7 @@ extension LumberTexture {
             context.setStrokeColor(UIColor(red: components[0],
                                            green: components[1],
                                            blue: components[2],
-                                           alpha: ring.depth).cgColor)
+                                           alpha: ring.depth).convertToGrayScaleColor().cgColor)
             context.strokePath()
         }

@@ -188,7 +188,7 @@ extension LumberTexture {
             context.setStrokeColor(UIColor(red: components[0],
                                            green: components[1],
                                            blue: components[2],
-                                           alpha: ring.depth).cgColor)
+                                           alpha: ring.depth).convertToGrayScaleColor().cgColor)
             context.strokePath()
         }

@@ -301,3 +301,12 @@ extension LumberTexture {
         return nil
     }
 }
+
+extension UIColor {
+    func convertToGrayScaleColor() -> UIColor {
+        var grayscale: CGFloat = 0
+        var alpha: CGFloat = 0
+        self.getWhite(&grayscale, alpha: &alpha)
+        return UIColor(white: grayscale, alpha: alpha)
+    }
+}

grayscale.png
明度の問題はなさそう。
もう少し木目のリアル感を上げ作り込みたい。

木目のリアル感を向上させる

木目のベースとなる色の幅が単色なためか単調な印象があるのと、少し色が暗い感じがするので色数を増やす。

LumberTexture.swift
final class LumberTexture {
    ...
    let baseColorComponents: [CGFloat] = [(255 / 255), (227 / 255), (220 / 255)]
    let centerBaseColorComponents: [[CGFloat]] = [
        [(205 / 255), (175 / 255), (131 / 255)],
        [(201 / 255), (138 / 255), (40 / 255)],
    ]
    ...

上面の年輪描画部分でベースとなる描画部分に80%の幅で2色のグラデーションを追加して自然に見せる。

LumberTexture.swift
    func lumberTopImage() -> CGImage? {

        UIGraphicsBeginImageContext(CGSize(width: side, height: side))
        guard let context = UIGraphicsGetCurrentContext() else { return nil }

        // Draw base color
        ...

        if let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
                                     colors: [
                                        UIColor(red: centerBaseColorComponents[0][0],
                                                green: centerBaseColorComponents[0][1],
                                                blue: centerBaseColorComponents[0][2],
                                                alpha: 0.3).cgColor,
                                        UIColor(red: centerBaseColorComponents[1][0],
                                                green: centerBaseColorComponents[1][1],
                                                blue: centerBaseColorComponents[1][2],
                                                alpha: 1).cgColor] as CFArray,
                                     locations: [0.7, 1.0]) {

            context.drawRadialGradient(gradient,
                                       startCenter: CGPoint(x: 0, y: side),
                                       startRadius: 0,
                                       endCenter: CGPoint(x: 0, y: side),
                                       endRadius: side * 0.8,
                                       options: [.drawsBeforeStartLocation])
        }
        ...
    }

上面の変更に追随するように前面の描画も併せて変更する。

LumberTexture.swift
    func lumberSideImage() -> CGImage? {

        UIGraphicsBeginImageContext(CGSize(width: side * sqrt(2), height: side))
        guard let context = UIGraphicsGetCurrentContext() else { return nil }

        // Draw base color
        ...

        if let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
                                     colors: [
                                        UIColor(red: centerBaseColorComponents[0][0],
                                                green: centerBaseColorComponents[0][1],
                                                blue: centerBaseColorComponents[0][2],
                                                alpha: 0.3).cgColor,
                                        UIColor(red: centerBaseColorComponents[1][0],
                                                green: centerBaseColorComponents[1][1],
                                                blue: centerBaseColorComponents[1][2],
                                                alpha: 1).cgColor] as CFArray,
                                     locations: [0.7, 1.0]) {

            context.drawLinearGradient(gradient,
                                       start: CGPoint.zero,
                                       end: CGPoint(x: side * 0.8, y: 0),
                                       options: [.drawsBeforeStartLocation])
        }
        ...
    }

太い年輪の一部の色が目立ちすぎているため色味を調整する

LumberTexture.swift
    let roughRingColorComponents: [[CGFloat]] = [
        [(176 / 255), (130 / 255), (71 / 255)], <-
        [(194 / 255), (158 / 255), (96 / 255)],
    ]
上面 前面 右側面
figure20-1.png figure20-2.png figure20-3.png
Before After
figure20-5.png figure20-4.png
  • 基本色が明るくなった
  • 細かい年輪がより目立つようになった
  • 木目の色に幅ができた

切断跡

木材をよく観察すると木の繊維とは別の加工する際についた傷が残っていることがある。
木の繊維に逆らう方向に切ることにより後が残ると思われる。
やや人工的に等間隔で描く。

LumberTexture.swift
    func lumberTopImage() -> CGImage? {

        ...

        // Draw scratch
        var pointer: CGFloat = 0
        repeat {

            context.setLineWidth(1)
            let startPoint = CGPoint(x: 0, y: pointer * sqrt(2))
            let endPoint = CGPoint(x: pointer * sqrt(2), y: 0)
            context.move(to: startPoint)
            context.addLine(to: endPoint)
            let alpha = (1 - pointer / side * sqrt(2))
            context.setStrokeColor(UIColor(white: 1, alpha: alpha).cgColor)
            context.strokePath()

            pointer += 6
        } while(pointer < side * sqrt(2))

        return context.makeImage()
    }

上面の奥から手前に徐々に効果がなくなるように描画。
わかりやすく赤で描画するとこんな感じ。

figure20-7.png

Before After
figure20-4.png figure20-6.png

効果は高くないが上面に色幅と新たな方向が加わった。

遊ぶ

タップして木目を切り替える

movie.gif

色情報の定義を分離する

色情報を外から入れられるようにすることでカスタム可能な作りにする。
構造体として LumberColorSet を定義して指定できるようにする。

LumberColorSet.swift
struct LumberColorSet {
    let baseColorComponents: [CGFloat]
    let centerBaseColorComponents: [[CGFloat]]
    let smoothRingColorComponents: [CGFloat]
    let roughRingColorComponents: [[CGFloat]]

    static var `default`: LumberColorSet {
        return .init(baseColorComponents: [
                        (255 / CGFloat(255)), (227 / CGFloat(255)), (220 / CGFloat(255))
                     ],
                     centerBaseColorComponents: [
                        [(205 / CGFloat(255)), (175 / CGFloat(255)), (131 / CGFloat(255))],
                        [(201 / CGFloat(255)), (138 / CGFloat(255)), (40 / CGFloat(255))],
                     ],
                     smoothRingColorComponents: [
                        (199 / CGFloat(255)), (173 / CGFloat(255)), (122 / CGFloat(255))
                     ],
                     roughRingColorComponents: [
                        [(176 / CGFloat(255)), (130 / CGFloat(255)), (71 / CGFloat(255))],
                        [(194 / CGFloat(255)), (158 / CGFloat(255)), (96 / CGFloat(255))],
                     ])
    }
}
LumberTexture.swift
final class LumberTexture {
    ...
    private var colorSet: LumberColorSet = LumberColorSet.default

    init(side: CGFloat, colorSet: LumberColorSet = LumberColorSet.default) {
        self.side = side
        self.base = createBase()
        self.smoothRings = createSmoothRings()
        self.roughRings = createRoughRings()
    }

クリスマスカラー

クリスマスカラーを定義してみる。

LumberColorSet.swift
struct LumberColorSet {
    ...
    static var xmas: LumberColorSet {
        return .init(baseColorComponents: [
                        (255 / CGFloat(255)), (245 / CGFloat(255)), (193 / CGFloat(255))
                     ],
                     centerBaseColorComponents: [
                        [(105 / CGFloat(255)), (58 / CGFloat(255)), (24 / CGFloat(255))],
                        [(223 / CGFloat(255)), (176 / CGFloat(255)), (39 / CGFloat(255))],
                     ],
                     smoothRingColorComponents: [
                        (0 / CGFloat(255)), (162 / CGFloat(255)), (95 / CGFloat(255))
                     ],
                     roughRingColorComponents: [
                        [(160 / CGFloat(255)), (28 / CGFloat(255)), (34 / CGFloat(255))],
                        [(255 / CGFloat(255)), (0 / CGFloat(255)), (0 / CGFloat(255))],
                     ])
    }
    ...
}

#FFF5C1 #693A18 #DFB027 #00A25F #A01C22 #FF0000
いい感じの模様になるまでタップを繰り返す。なんかちょっと怖いかも。スイカっぽいし。
figure21-1.png

画像から色を抽出

RGBを定義するのが面倒なので画像から代表的な色を抽出するコードを書いてみた。

LumberColorSet.swift
    init?(image: UIImage) {

        guard let components = image.cgImage?.getPixelColors(count: 6),
              components.count == 6 else {
            return nil
        }

        self.init(baseColorComponents: [
                    components[0][0], components[0][1], components[0][2]
                  ],
                  centerBaseColorComponents: [
                    [components[1][0], components[1][1], components[1][2]],
                    [components[2][0], components[2][1], components[2][2]]
                  ],
                  smoothRingColorComponents: [
                    components[3][0], components[3][1], components[3][2]
                  ],
                  roughRingColorComponents: [
                    [components[4][0], components[4][1], components[4][2]],
                    [components[5][0], components[5][1], components[5][2]]
                  ])
    }

iOS14から追加された CIFilterCIKMeans を使用してイメージの代表色を抽出してみる。k-means法で代表色を抽出するアルゴリズムらしい。

6色必要なので "inputMeans" に代表的な6色を与え、 filter.outputImage が 6 x 1で得られるような属性にする。
属性はこちらのURLを参考にした。

LumberColorSet.swift
    private func colorSampleImage(count: Int) -> CGImage? {

        let inputImage = CIImage(cgImage: self)

        guard let filter = CIFilter(name: "CIKMeans") else { return nil }
        filter.setDefaults()
        filter.setValue(inputImage, forKey: kCIInputImageKey)
        filter.setValue(inputImage.extent, forKey: kCIInputExtentKey)
        filter.setValue(64, forKey: "inputCount")
        filter.setValue(10, forKey: "inputPasses")
        let seeds = [CIColor(red: 0, green: 0, blue: 0),    // black
                     CIColor(red: 1, green: 0, blue: 0),    // red
                     CIColor(red: 0, green: 1, blue: 0),    // green
                     CIColor(red: 1, green: 1, blue: 0),    // yellow
                     CIColor(red: 0, green: 0, blue: 1),    // blue
                     CIColor(red: 1, green: 1, blue: 1)]    // white
        filter.setValue(seeds, forKey: "inputMeans")
        guard let outputImage = filter.outputImage else { return nil }

        let context = CIContext(options: nil)
        return context.createCGImage(outputImage, from: CGRect(origin: .zero, size: outputImage.extent.size))
    }

上記で取得した6 x 1のイメージから1ピクセルずつRGBを取得する。

LumberColorSet.swift
    private func getPixelColors(count: Int) -> [[CGFloat]] {

        var components: [[CGFloat]] = []
        guard let importantColorImage = colorSampleImage(count: count) else { return components }

        (0...count).forEach { index in

            let scale: CGFloat = 1
            let rect = CGRect(x: CGFloat(index) * scale, y: 0, width: scale, height: scale)
            if let cropImage = importantColorImage.cropping(to: rect),
               let color = cropImage.averageColor() {

                var r: CGFloat = 0
                var g: CGFloat = 0
                var b: CGFloat = 0
                var a: CGFloat = 0
                color.getRed(&r, green: &g, blue: &b, alpha: &a)
                components.append([r, g, b])
            }
        }
        return components
    }

1 x 1のイメージから平均色を抽出する。

    private func averageColor() -> UIColor? {

        let inputImage = CIImage(cgImage: self)

        guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: inputImage, kCIInputExtentKey: inputImage.extent]) else { return nil }
        guard let outputImage = filter.outputImage else { return nil }

        var bitmap = [UInt8](repeating: 0, count: 4)
        let context = CIContext(options: nil)
        context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil)

        return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: CGFloat(bitmap[3]) / 255)
    }

クリスマスツリーから抽出した結果
*適当に拾ったフリー素材
xmas_tree.png

抽出した結果は以下の色になった。
#063902 #241410 #070707 #080808 #050505 #797979

参考にしたサイトでも言及されている通り抽出した色はやや意図通りではなくk-means法による?補正があるようなので、以下で彩度と明度を調整してみる。

let color = cropImage.averageColor()?.color(mimimumBrightness: 0.5).color(mimimumSaturation: 0.5)
extension UIColor {

    func color(mimimumBrightness: CGFloat) -> UIColor {

        var h: CGFloat = 0
        var s: CGFloat = 0
        var b: CGFloat = 0
        var a: CGFloat = 0
        getHue(&h, saturation: &s, brightness: &b, alpha: &a)

        if b < mimimumBrightness {
            return UIColor(hue: h, saturation: s, brightness: mimimumBrightness, alpha: a)
        }
        return self
    }

    func color(mimimumSaturation: CGFloat) -> UIColor {

        var h: CGFloat = 0
        var s: CGFloat = 0
        var b: CGFloat = 0
        var a: CGFloat = 0
        getHue(&h, saturation: &s, brightness: &b, alpha: &a)

        if s < mimimumSaturation {
            return UIColor(hue: h, saturation: mimimumSaturation, brightness: b, alpha: a)
        }
        return self
    }
}

明度のみ調整した結果
#0D8004 #804739 #808080 #808080 #808080 #808080

明度も彩度も調整した結果
#0D8004 #804739 #804040 #804040 #804040 #804040

うーん、、、あまり期待値は得られなかった。
figure22-1.png

明らかに取れそうなイメージで試してみる。
ラスタカラー。
rasta.jpg

#010101 #560200 #035500 #545400 #000000 #000000
明度・彩度を弄った色
#804040 #800300 #058000 #808000 #804040 #804040

なんとなく近い色は取れたけどやはり意図しない補正がかかってしまう。
figure22-2.png

杉や赤松など他の木目イメージから色をサンプリングして適用してみたけど、
あまり良い結果が得られなかったので割愛する。。

感想

コードで木目という有機的なものを描こうと思ったことはなかった。
規則性を見出すことでコードに落とし込める発見があるのと、よりリアルに近づけていくにはどういうイレギュラーを加えれば良いのか試行錯誤するのが楽しかった。
木の節はどこかに入れてみたかったが難しくて断念した。

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

【Swift 5】線を引く。

みさなんこんちには、@Zhalen(ツァーレン)です。

もうすぐクリスマスですね。このマスクばかり売れて人々が隔離され企業の悪い経営システムすらも浮き彫りになりそれだけでなく在宅でできることに関する産業の需要が増し以前とはだいぶ違った世の中になったこともさることながらただ徒らに時がすぎてゆく中、如何お過ごしでしょうか。

私は、虎視淡々と未来を見据えています。そしてとても空腹です。

この記事では、この画像の、
IMG_0921.PNG
IMG_0922.jpg

このように任意の箇所に直線を引くメソッドを作ったので紹介します。

Usage: 使い方

結論としては、このようにして使えるようになります。

UIViewController-viewDidLoad
<UIView>.drawLine(start: <CGPoint>, end: <CGPoint>, color: <UIcolor>, weight: <CGFloat>, rounded: <Bool>)

まずはカスタムクラスを作りましょう

線を引くために、BezierView(ベジェビュー)という汎用的なカスタムクラスを作成して、それを使いまわします。

class BezierView: UIView {

    var start: CGPoint = .zero
    var end: CGPoint = .zero
    var weight: CGFloat = 2.0
    var color: UIColor = .gray
    var isRounded: Bool = true

    override init(frame: CGRect) {
        super.init(frame: frame);
        self.backgroundColor = UIColor.clear;
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func draw(_ rect: CGRect) {

        let line = UIBezierPath()
        line.move(to: start)
        line.addLine(to: end)
        line.close()
        color.setStroke()
        line.lineWidth = weight
        line.lineCapStyle = isRounded ? .round : .square
        line.stroke()
        self.isUserInteractionEnabled = false
    }

}

ここで

    var start: CGPoint = .zero
    var end: CGPoint = .zero
    var weight: CGFloat = 2.0
    var color: UIColor = .gray
    var isRounded: Bool = true

とありますがこれはデフォルト値で、それぞれ

    var start: CGPoint = //線の開始地点
    var end: CGPoint = //線の終了地点
    var weight: CGFloat = //線の太さ
    var color: UIColor = //線の色
    var isRounded: Bool = //角を丸くするかどうか

を表します。これらの情報を用いて、Extensionを作成します。

こちらがそのExtensionです。

extension UIView {
    func drawLine(start: CGPoint, end: CGPoint, color: UIColor, weight: CGFloat, rounded: Bool) {
        let line: BezierView = BezierView(frame: CGRect(x: 0, y: 0, width: max(start.x , end.x)+weight, height: max(start.y, end.y)+weight))
        line.start = start
        line.end = end
        line.color = color
        line.weight = weight
        line.isRounded = rounded
        self.addSubview(line)
    }
}

こちらはシンプルなエクステンションを定義しましたが、より一般的に定義したい場合は、複数の点を用いて

extension UIView {
    func drawLine(points: [CGPoint], color: UIColor, weight: CGFloat, rounded: Bool) {
        guard points.count >= 2 else { fatalError("Line is not drawable because points are less than 2") }
        for i in 0..<points.count-1 {
            self.drawLine(start: points[i], end: points[i+1], color: color, weight: weight, rounded: rounded)
        }
    }
}

のようにすることも可能です。実際に上の画像での折れ線はこのpoints付きのやつを使っています。書く分には損はないので、念のためこれも書いておきましょう。

コピペ用コード全文です。

class BezierView: UIView {

    var start: CGPoint = .zero
    var end: CGPoint = .zero
    var weight: CGFloat = 2.0
    var color: UIColor = .gray
    var isRounded: Bool = true

    override init(frame: CGRect) {
        super.init(frame: frame);
        self.backgroundColor = UIColor.clear;
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func draw(_ rect: CGRect) {

        let line = UIBezierPath()
        line.move(to: start)
        line.addLine(to: end)
        line.close()
        color.setStroke()
        line.lineWidth = weight
        line.lineCapStyle = isRounded ? .round : .square
        line.stroke()
        self.isUserInteractionEnabled = false
    }

}
extension UIView {
    func drawLine(start: CGPoint, end: CGPoint, color: UIColor, weight: CGFloat, rounded: Bool) {
        let line: BezierView = BezierView(frame: CGRect(x: 0, y: 0, width: max(start.x , end.x)+weight, height: max(start.y, end.y)+weight))
        line.start = start
        line.end = end
        line.color = color
        line.weight = weight
        line.isRounded = rounded
        self.addSubview(line)
    }
    func drawLine(points: [CGPoint], color: UIColor, weight: CGFloat, rounded: Bool) {
        guard points.count >= 2 else { fatalError("Line is not drawable because points are less than 2") }
        for i in 0..<points.count-1 {
            self.drawLine(start: points[i], end: points[i+1], color: color, weight: weight, rounded: rounded)
        }
    }
}

ありがとうございました

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

UITextDocumentProxyのselectedText, documentContextBeforeInput, documentContextAfterInputの仕様について

先日、この記事で改行をトリガーにしてselectedTextがおかしくなる現象について紹介しました。
UITextDocumentProxyのselectedTextは3行以上かつ改行含めて65文字以上選択していると正しい選択部分を返さない

しかし続けて調べたところ、selectedTextのみならずdocumentContextBeforeInputとdocumentContextAfterInputにも類似の仕様があり、さらにトリガーになっているのは改行というよりむしろ「文」であり、また文字数ではなくバイト(utf16.count)であることが分かりました。

結果

documentContextBeforeInputとdocumentContextAfterInput

  • documentContextBeforeInputは「前の一文」を返します。これはバイト数に依りません。
  • documentContextAfterInputは「後の一文」を返します。これはバイト数に依りません。

前後の一文

ここでいう「文」の定義ですが、確認できている限り以下の文字によって区切られているものを指します。

  • 英語のピリオド.
  • 日本語の句点
  • 改行\n
  • 全角・半角の感嘆符!
  • 全角・半角の疑問符?

なお、例外的に「英文(英字と空白とピリオドによる構成)」ではこの動作が確認できませんでした。英字+句点や英字+半角感嘆符では動作が確認できたので、何かが違います

末尾にこれらの記号が付く、または終端に達するまでを「1文」として扱います。ただしこれらの記号が複合した形、例えば「。。。」や「!?」などは1つの区切りとして扱われているようです。

selectedText

  • selectedTextは「3文」以上かつ「65バイト」以上選択していると、最初と最後の2文を連結したものを返してきます。

3文

ここでいう「文」の定義ですが、確認できている限り以下の文字によって区切られているものを指します。

  • 英語のピリオド.
  • 日本語の句点
  • 改行\n
  • 全角・半角の感嘆符!
  • 全角・半角の疑問符?

documentContextシリーズと同様、末尾にこれらの記号が付く、または終端に達するまでを「1文」として扱います。

65バイト

前回の記事では65文字と書きましたが、試してみると例えば2バイトである「?」を使ってみると切れ目が変わります。「????……」を21個連ねる部分までは全体が選択されますが、「??×21+?」に至ったタイミングで正しく選択できなくなります。また3バイトの「飴?(飴に異体字セレクタがついたもの)」を用いてみると「飴?!×16+飴?」で正しく選択できなくなります。これらのことから、おそらく書記素クラスタでの文字数ではなくutf16としての文字数で判断されているようです。

不具合ではない

前回の記事にも追記しましたが、Appleからは「クラッシュさせないためのexpected behavior」だよ、との趣旨の回答をもらいました。これがunexpectedな振る舞いだったら怖いのでよかったです。
この制限自体はおそらくkeyboard extensionに想定外に大量のデータを与えてしまわないようにするためのものでしょう。リミットが適切かは別として、存在自体は納得できました。

影響

自作しているキーボードにつけていた「文字数カウント」はselectedTextを利用していたのですが、この仕様上まともに動作させるほうが困難なので削除しました。現状、受け取った文字列が確実に全体を反映しているとわかる状況は「内部に区切り文字を含んでいない状況」のみです。区切り文字のリストが公開されていない以上、私が発見した以外にも区切り文字があるかもしれないので、この判定方法も潜在的に不具合の可能性を孕みます。

受け取っているテキストが正しいものなのか正しくないものなのかを判断することが出来ない点がかなり苦しいです。どうにかならないでしょうか。

また、この辺りの振る舞いはほとんどドキュメントが見つからず、気づいていない仕様がまだまだある可能性が高いです。今後何かわかったら追記します。

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

[swift5]Twitterシェア機能の実装方法

投稿内容

今回は、アプリ内でそのアプリをシェアしたいときに扱うコードの紹介をします。
よく見かけるのは、TableViewでセルをタップするとTwitterへ遷移し、tweet投稿画面が開くあれですね。

実装コード

まずはコードの紹介。

//シェア用テキスト
let shareText = "ここにシェアしたいテキストを記述"

//URLクエリ内で使用できる文字列に変換
guard let encodedText = shareText.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return }
guard let tweetURL = URL(string: "https://twitter.com/intent/tweet?text=\(encodedText)") else { return }

//URLに載せてシェア画面を起動
UIApplication.shared.open(tweetURL, options: [:], completionHandler: nil)

思いのほかシンプルな実装ですね!

備考

ユーザーがTwitterアカウントを所持している場合はTwitterへ遷移、所持していない場合はアカウント作成を促す画面に遷移する模様。

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

【Swift】型の種類〜基礎知識〜

Swiftには、構造体クラス列挙型という3つの型の種類があります。

それぞれプロパティやメソッドなどの共通した仕様がある一方で、
三者それぞれの固有の仕様も多く存在します。

これらの仕様の違いは、
単純な機能の有無だけではなく値の受け渡し時の挙動にも及びます。

型の種類を使い分ける目的

先ほども記載しましたが、型には構造体とクラス、列挙型が存在します。

これらの型は共通の構成要素を所持しており、
たいていのデータは構造体やクラスで表現できるようになっています。

しかし、わざわざ別れているくらいなので察すると思いますが、
構造体とクラス、列挙型はそれぞれの目的に特化した機能を持っています。

なので、データの性質に応じて適切な種類を選択すれば、
単なる値と機能の組み合わせ以上の表現が可能になります。

値の受け渡し方法による分類

Swiftの3つの型の種類は、値の受け渡しの方法によって、
値型参照型の2つに大別することができます。

この2つの違いは、変更を他の変数や定数と共有するかどうかにあります。

変更を共有しない型が値型であるのに対し、
変更を共有する型が参照型になります。

Swiftでは、構造体と列挙型は値型、クラスは参照型として実装されています。

値型

値型は、インスタンスが値への参照ではなく値そのものを表す型になります。

変数や定数への値型のインスタンスの代入は、
インスタンスが表す値そのものの代入を意味します。

なので、複数の変数や定数で一つの値型のインスタンスを共有することはできません。
そのため、一度代入したインスタンスは再代入をしない限り不変です。

このことから、
値型のメリットは、その値の予測が可能になることが挙げられます。

標準ライブラリで準備されている値型は多くありますが、
その中でも特に使用する値型はInt型が挙げられます。

下記のサプルコードをご覧ください。

変数bに対して変数aを代入していますが、この代入しているaとは、
aが持つ値への参照ではなく、aが持つ4という値そのものになります。

つまり、aとbは別々の4というインスタンスを所持しているため、
aに対して再代入を行ってもbの値は変わらず4のままになります。

var a = 4
var b = 4
a = 2
print(a)
print(b)

実行結果
2
4

mutating

値型では、mutatingキーワードをメソッドの宣言に追加することで、
自身の値を変更する処理を実行できます。

mutatingキーワードが指定されたメソッドを実行してインスタンスの値を変更すると、
インスタンスが格納されている変数への暗黙的な再代入が行われます。

mutatingキーワードが指定されたメソッドは再代入のメソッドとして扱われるため、
定数に格納された値型のインスタンスに対して実行するとコンパイルエラーになります。

記述方法としては下記のようになります。

mutating func メソッド名(引数) -> 戻り値の型 {
    メソッド呼び出し時に実行される文
}

サンプルコードでは、エクステンションを利用して、
Int型にincrement( )メソッドを追加しています。

このメソッドは、a.increment()のように実行できるため
一見すると代入を伴わず値の変更をしているように見えます。

しかし、実際は再代入が行われているので、
定数であるbに対してincrementメソッドを実行するとコンパイルエラーになります。

extension Int {
    mutating func increment() {
        self += 1
    }
}

var a = 1
a.increment()   // 2

let b = 1
b.increment()   // コンパイルエラー

エラー内容:Cannot use mutating member on immutable value: 'b' is a 'let' constant
和訳:不変の値に変更メンバーを使用することはできません:「b」は「let」定数です

mutatingキーワードをつけなければ、再代入として扱われないのですが、
先頭にmutatingをつけないでメソッドを定義すると
そもそも自身の値にアクセスできなくなります。

つまり、mutatingを利用する利点としては、
・自身の値を変更することができる。
・定数に対しては実行できなくなる。

が挙げられると思います。

2つ目の利点は、インスタンスが保持する値の変更を防ぎたい場合に役立ちます。

値の変更を防ぐということは、
予期せぬ値の代入を防ぐことができるということです。

つまり安全性が向上します。

参照型

参照型とは、インスタンスが値への参照を表す型です。
Swiftにおけるクラスは参照型になります。

変数や定数への参照型の値の代入は、
インスタンスに対する参照の代入を意味するため、
複数の定数や変数で1つの参照型のインスタンスを共有できます。

値の変更の共有

値型では、変数や定数が他の値の変更による影響を受けないことが保証されていました。

それに対して、参照型では1つのインスタンスが他の変数や定数と共有されているため、
ある値に対しての変更はインスタンスを共有している他の変数や定数にも影響します。

文字を書いていても伝わりにくと思いますので、
実際にサンプルコードを記述してみました。

class Sample型を定義しました。

var a = Sample(value: 1)
var b = a
変数aにSample(value: 1)を代入し変数bにはaを代入しています。

参照型のインスタンスであるaをbに代入するということは、
aが参照しているインスタンスをbも参照するということになります。

つまりaとbは、同じSample(value: 1)への参照を持っていることになります。
そのためa.valueを変更するとb.valueも変更されます。

class Sample {
    var value: Int

    init(value: Int) {
        self.value = value
    }
}

var a = Sample(value: 1)
var b = a

a.value   // 1
b.value   // 1

a.value = 2

a.value   // 2
b.value   // 2

値型と参照型の使い分け

値型は変数や定数への変更が共有されません。
したがって、一度代入された値は明示的に再代入しない限り不変になります。

一方で参照方はその逆であり、変数や定数への変更が共有されます。
したがって、一度代入された値が変更されないことの保証は難しくなります。

これらの性質を考慮すると、
安全にデータを扱うためには積極的に値型を使用し、
参照型は変更の共有が必要となる範囲のみにとどめるのがベストだと思います。

以上で型の基礎知識についての説明は終了します。

構造体とクラス、列挙型についての説明も記事にしていますので、
お時間がある際にぜひご覧ください!

【Swift】型の種類〜構造体〜
【Swift】型の種類〜クラス前編〜
・【Swift】型の種類〜列挙型〜

最後までご覧いただきありがとうございました。

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