- 投稿日:2020-12-16T23:31:41+09:00
まだ、CollectionViewで疲弊してるの?iOS12から始めるCompositionalLayout~実装から設計まで~
どうもこんにちは、忘年会をなかなか開催できず今年は悲しいTOSHです。
本日はZOZOテクノロジーズアドベントカレンダー17日目を担当させてもらいます!はじめに
さて、iOSエンジニアなら誰でも、CollectionViewを使っていますよね。
ただ、皆さん薄々感づいているように、アプリのUIは依然と比べて、ますます複雑なものになってきています。以下例
AbemaTV RakutenNBA UberEats AppleStore エンジニアからしたら、これらを実装するのに、まず、全体を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.swiftprotocol 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.swiftimport 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.swiftfunc 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を作成する方法は大きく分けて二通りあります。
- Sectionに対して、Header,Footerを設定する
- Sectionの上下にHeader, FooterとなるSectionを追加する。
これらの方法にはメリットデメリットがあると思いますが、Header, Footerに対して、複雑なタッチイベントを追加したい場合は2の方法がよく、特にタッチイベントを使用しないまたは、タップによってアコーディオンのような処理のみしか行わない場合であれば、1の方法の方が楽なのかなと思います。
1の方法の実装方法
先ほどのSectionに対して、もう一つメソッドを追加します。
Section.swiftProtocol Section { ~~省略~~ // HeaderやFooterを使用しない場合は、UICollectionReusableView()を返す func configureHeaderFooter(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView }次にItemsSectionにも新しく追加したメソッドを足しましょう!
ItemsSection.swiftstruct 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.swiftfinal 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についてはまた今度記事にしようと思います!
それではっ!
- 投稿日:2020-12-16T22:35:40+09:00
【Swift】Map Kit Viewを使った基本機能のテンプレートと解説
これはなに
Map Kit View
を設置したときコントローラに書くテンプレートのようなものとその解説。
ひとつの画面に Map Kit View と Label があり、マップの特定箇所を長押しするとLebel部分に住所が表示されるという想定。テンプレートと解説
ViewController.swiftimport 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
- ストーリーボードでMap Kit Viewの上に重ねて設置する
- ストーリーボードのView Controller上部バー(?)に作られたアイコンをcontrolキーを押しながら
View Controller.swift
上にドラッグドロップしてつなぐ- Type を下の画像のように UILongPressGestureRecognizer に設定してConnectを押す
プロパティと中の値について
参考
iOS11でCLPlaceMarkのnameで取れる値が変わったと思って検証したら、奇妙なプロパティであることがわかった
https://qiita.com/fr0g_fr0g/items/356d88ec906f2004f5f6
こんな感じです。
ぼんやりとした理解でちょっとどんくさいコードのような気もします。
- 投稿日:2020-12-16T22:23:26+09:00
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 }
対応だよ?はい、そうです。しかし、ここで設定できるのは、次の画面に遷移された時の戻るボタンです。つまり、これは戻り先が自分の時の設定です。さらに
backBarButtonItem
にaction
を設定しても、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 の素直な実装で動いてくれるからとても気持ちいいです。ViewControllerBnavigationController?.pushViewController(vc, animated: true) // 次の画面 C への Push 遷移 navigationController?.viewControllers.removeAll(where: { $0 === self }) // その直後に自身を NavigationController の viewControllers スタックから削除もちろんこれが完璧と言うわけでもありません、一つだけ細かい問題があります:それは画面 C に遷移した直後に、一瞬だけ画面 B の名前が戻るボタンの場所に表示されることです。表示は一瞬だけで、遷移が終わったら画面 B はスタックから消えるので、画面 A の名前に変わります。
【オルタネイティブ】そもそも Push 遷移しない
ちなみに、アップルの公式アプリでも似たような処理があります、それは設定アプリからパスコードを設定する画面に入るところです。この画面も、パスコードの設定画面に入る前に一回認証処理が挟まり、戻るとき当然ながらその認証画面には行かないで設定一覧画面に戻ります。ではアップルがどうしてるかと言うと、認証画面は Push 遷移ではなく Modal 遷移です、そして認証が通ったら裏でパスコード画面に Push 遷移済みの状態にした上で認証画面を Dismiss します。
このような処理も、見た目として特に違和感なく自然に見えますが、ただしそもそもの話、設定一覧画面から表示されている「遷移先」が「パスコード設定」であり、「認証」ではありません。だからこそ認証画面を Modal 遷移しても特に違和感を覚えません。ユーザは「パスコード設定画面」を「Push 遷移」で期待しているからです。しかし逆に遷移先を「画面 B」と記載しながら Modal 遷移すると、違和感を覚えるでしょう。
後書き
難しそうな機能でも、ときにはちょっとアプローチを変えるだけで、すんなり解決することもあります。
- 投稿日:2020-12-16T19:25:57+09:00
【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】型の種類〜列挙型〜最後までご覧いただきありがとうございました。
- 投稿日:2020-12-16T19:07:39+09:00
MacinCloud導入について
MacinCloudって?
Windowsでもmacが使えるサービスです!!
MacincloudはMacOSを仮想サーバーのような形で提供しているサイトです!
要は、世界のどこかにあるサーバーにMacOSがあって、それをネットを介して覗いてみて、遠隔操作するってイメージです!
これがあれば、WindowsでもMacOSが使え、XCodeも元々入っているので、iPhoneアプリ開発ができます!
プランによりますが、一番安いプランだと月20ドル~からできるみたいです。
大体30ドルくらいで申し込む人が、僕の周りでは多いです!早速導入してみましょう!
MacinCloudにアクセスして、
TRY IT NOW
を押しましょう.値段の見方
各項目についてチェックしていきましょう!
Operating System Version
MacOSには何個かバージョンがあります!!
2020/12/16現在ではmacOS Catalina10.15.7
がおすすめです!
最新はBigSurですが、リリースされた直後で、これに対応していないアプリがいくつかあるって言う噂です。。
ここに関してはOSについていろんなところを検索して、検討してみてください!Platform/Models
Macのスペックの話です!
良いほど、料金が上がります。
CPUのバージョンなど指定できます。PCのスペックってざっくり3種類のモノで評価します。
わかりやすくイメージするなら、料理をイメージして下さい!
料理って早く出てきて欲しいモノですよね。
①CPU
・・・料理人の腕。これがいいと料理って早く出てきます。画像で言うと、3.6GHz quad-core Intel Core i3の部分。i7とか、iの値が増えると、腕が良いイメージ。
②メモリ(RAM)
・・・作業台の広さ。料理人の腕が良くても、作業スペースが狭かったら、すぐキャパります。下のAddonsのところで設定できます。
③ストレージ(ROM)
・・・ちょっと遠くにある冷蔵庫の大きさ。冷蔵庫が大きいといろいろできます。ちょっと遠いって言ったのは、メモリの方が料理人の近くにあるのに対して、ストレージは遠くにあるので、材料を撮ってくるのにちょっと時間かかるってことです。MacInCloudにおいては2TBとかアホほど大きいので、気にしなくて大丈夫そうです。Location
多分みなさん、日本に住んでいるので、Asia East (Near Singapore)で大丈夫です。ここは料金に反映しないです。Paytment Cycle & Login Time
いつ払いますか〜ってことです。Monthlyを選択肢しましょう!
MacinCloudは一日にログインできる時間に制限があります!
最低は5時間は欲しいところ、、、
後から変更できるのかな、??Addons
アッドンじゃないよアドオンだよ。
RAMについてはこの記事を見てください!Platform/Modelsの項目で述べた、メモリの話です!
Resolution SupportとInternal/External GPUは画像の通りにチェックしとけば良いと思います!支払い、個人情報記入
案内にしたがって記入してください!
最後入力情報確認して終わり!
です!
リモートデスクトップを行うアプリをまとめてダウンロードできるページがあると思うので
そこから、ダウンロードして、お好みの画面サイズのファイルダブルクリックして、ログインしたら、
環境構築完了!!
- 投稿日:2020-12-16T19:07:39+09:00
(書き途中)MacinCloud導入について
MacinCloudって?
Windowsでもmacが使えるサービスです!!
MacincloudはMacOSを仮想サーバーのような形で提供しているサイトです!
要は、世界のどこかにあるサーバーにMacOSがあって、それをネットを介して覗いてみて、遠隔操作するってイメージです!
これがあれば、WindowsでもMacOSが使え、XCodeも元々に入っているので、iPhoneアプリ開発ができます!
プランによりますが、一番安いプランだと月20ドル~からできるみたいです。
大体30ドルくらいで申し込む人が、僕の周りでは多いです!早速導入してみましょう!
MacinCloudにアクセスして、
TRY IT NOW
を押しましょう.
値段の見方
各項目についてチェックしていきましょう!
Operating System Version
MacOSには何個かバージョンがあります!!
2020/12/16現在ではmacOS Catalina10.15.7
がおすすめです!
最新はBigSurですが、リリースされた直後で、これに対応していないアプリがいくつかあるって言う噂です。。
ここに関してはOSについていろんなところを検索して、検討してみてください!
- 投稿日:2020-12-16T16:58:38+09:00
【これからiOS頑張りたい方向け】2年半iOSアプリ開発をしてハッとした瞬間まとめ
はじめに
iOS Advent Calendar 2020
17日目です。2年半くらいiOSアプリ開発してきてハッとした瞬間をまとめました。(iOSとかswiftに限った話じゃない学びもあるけど。)
がんばらなくても読めるけど、なんとなく勉強にもなる記事を目指しました。
タイトルに近い方が初歩的なやつです。
もし時間あればみていただけると嬉しいです。お品書き
- 返り値でBoolを返す時はそのBool自身を返せばいい
- 三項演算子を使うとif else がワンライナーで書ける
- var +=は計算型プロパティにできる。
- ネストは早期returnで減らせる
- 2重否定はifでいい。
- 型が明確な時のinitializerは.initに省略できる
- trailing closureは引数から省略できる
- enumとswitchを組み合わせて網羅性をチェックする
- Bool値が複数ある場合の場合分けはswitchとパターンマッチを使うと見やすくなる。
- var+forは大抵高階関数で書き換えられる
- 配列のmappingにはmapが使える
- for文にifを挟む処理はfor+whereに書き換えできる。
- for+whereはfilter+forEachに書き換えできる。
- 引数の変更を参照元に反映させたい時はinoutが使える
- filter + first or lastはfirst(where: ) or last(where: )で書ける。
- StackViewを使うと制約をグッと減らせる。
- StackView + ScrollViewでお手軽に拡張しやすいスクロール可能なレイアウトを構築できる。
- 見えているViewの裏のViewのタッチイベントを取得したい時はhittestが使える
- 同じ処理をアノテーション化して省略する時はPropertyWrapperが使える
返り値で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 titlesAfter
(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+where
はfilter+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 last
はfirst(where: ) or last(where: )
で書ける。Before
animals.filter { !($0 is Runable) }.first // PenguinAfter
animals.first { !($0 is Runable) } // Punguinfirstを使う方がパフォーマンスにも優れるとかなんとか。
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.accessTokenAfter
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 //getkeyとプロパティを増やせばsetとgetは書かなくてよくなりますね。
終わりに
読んでいただいてありがとうございました!
もっといっぱいあると思うんですけどいざ出そうとすると出てこないんですよね。
明日は@takashicoさんです!
- 投稿日:2020-12-16T14:45:05+09:00
エラーメッセージ 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度されてないかを確認する。
参考にした記事
- 投稿日:2020-12-16T11:34:12+09:00
[iOS]コードから追加したUI部品がSafe Area内に収まるようにするには
やり方
iOS11より前では
topLayoutGuide
とbottomLayoutGuide
を組み合わせて使う。
iOS11以降だとSafeAreaLayoutGuide
に一本化されたのでそれを使う。ExampleViewController.swiftprivate 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?
- 投稿日:2020-12-16T11:24:47+09:00
ViewとLayerで立方体をデッサンしてみる
昨年の球体デッサンに続き今年は立方体をデッサンしてみた。
今回は写真や画像を一切使わない正真正銘完全コーディングによるデッサンに拘った。(前回は一部写真を歪めてはめ込むチート技を使ってしまった)木の角材に見えるかな?
主にCoreAnimation
CoreGraphics
CoreImage
を利用している。プロジェクトは以下にアップした。
https://github.com/yumemi-ajike/Cube
- Xcode 12.2
- Swift 5.1
立方体の面
前面、背面、上面、底面、左側面、右側面のそれぞれをCALayerとして定義、正方形で追加する。
サイズは適当に一辺200とした。WireCubeView.swiftlet 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.swiftoverride 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)しか見えていない状態となる。
立方体として認識できる見栄えにする必要があるため、全体の角度を調整する。
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 = transform6面全面の親である、baseLayerにtransformをかける。
Y軸を−30°、X軸を-30°、Z軸を15°傾けるとなんとか良い感じに立方体に見えなくもない。
因みに傾けると線が微妙な角度で描画されジャギって汚くなるため各レイヤーにはアンチエイリアスをかけておく。layer.allowsEdgeAntialiasing = trueこのままだとパースペクティブが効いていないため奥行きが感じられずどっち方向にあるのか判別できない。
transform.m34 = -1.0 / 1000CATransform3D.m34 に値を入れることでパースがかかりどういう方向に存在しているのかわかるようになる。値は適当。
しっかり立方体に見えるようになった。
各面の見えるバランスも良さげ。面を作る
グラデーションで面の微妙な明暗を表現する。
見えている前面、上面、右面を
CAGradientLayer
にして色を付ける。CubeView.swiftlazy 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.swiftfunc 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%の白地にグレーのグラデーションがかかるようにしている。
前面と右面を下方向に明るくしているのは接地面の反射光が当たり明るくなっているという表現。背景/地面を作る
CubeView
の後ろにGroundView
を追加する。
背景/地面(=GroundView)は立方体(=CubeView)とは別の階層として定義し、立方体そのものの再利用性を高めている。GroundView.swiftfinal 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 } }影を落とす
背景/地面ができたので立方体の影を落とす。
影は背景/地面 or 立方体どちらのView階層に存在すべきか議論が分かれそうだが、
影も立方体と同じパース具合にしたいのでtransformの記述が1箇所で済みそうなCubeViewのbaseLayerに追加することにした。どうやって影を表現するか試行錯誤したけど、立方体の各面から影を作り出すことが難しかったので結局影としての面を全く別に設けることにした。
影のベースレイヤーを手前に広がるように追加する。
CubeView.swiftlazy 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.swiftoverride func layoutSubviews() { super.layoutSubviews() ... baseLayer.addSublayer(shadowLayer) ... }影となるグラデーションを落とす。
CubeView.swiftlazy 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.swiftoverride func layoutSubviews() { super.layoutSubviews() shadowLayer.addSublayer(shadowGradientLayer) ... }影の形をパスでクリップする。
CubeView.swiftlazy 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.swiftoverride func layoutSubviews() { super.layoutSubviews() ... shadowGradientLayer.mask = shadowShapeLayer ...グラデーションに対してこのようなパスでクリップすることで影にみせかける。
それらしくなっているが何の立方体かわからないし、リアルさにかけるためCG丸出し感が否めないので詳細を作り込んでいく。
形状の作り込み
立方体の形状などディティールを追求していくことで画としての説得力を上げていく。
角を落とす
各面同士が直角に接している部分が現実の物体ではありえないほど鋭い。
この時点ではまだ素材は明らかになっていないが現実のものなら金属でも木材でもプラスティックでも必ずわずかな角の丸みがあるはずなので角を落としていく。角を丸めて内容をクリップしたいため
CAGradientLayer
をCALayer
の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を大袈裟な値にするとこんな感じでサイコロのようになる。
立方体の形状と影が合わないため、立方体に合った影の形に
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 }()接地面の影
立方体の接地面の影をしっかり描くことで物体がある面に接地していることをより表現できる上に、画にパシッと締りができる。
立方体の底面である
bottomLayer
にshadowを設定することで実現してみる。。。
一見、おかしくないようだがよく見ると画面左端の影の落ち方がおかしい。
また角を丸めているはずなのに影が濃すぎるという問題も浮かび上がる。
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 }()
shadowBaseLayer
はbottomLayer
の矩形に対して角丸分だけ内側にレイアウトされるようにすることで底面の周囲が接地していない表現を行う。台に置かれたサイコロの周囲がぴったり接地していないのと同じ。
一旦、作り込み前後の比較
ハイライト/影なし ハイライト/影あり 画のリアル感はやや向上した。形状や状態がより伝わりやすくもなったが、如何せん何でできているのか素材や質感が全くわからないのでデッサンとしてはNGもう一歩。
素材や質感
立方体の素材を描くことによって質感を表現し、さらに説得力を上げていく。
ガラス製だと立方体が置かれている周りの風景の映り込みを表現する必要があり、これをコードのみで表現するのは難しそう。また前回のように写真を使ってしまいそうだ。
プラスティック製も表面がツルツルしているため、映り込みの表現が必要だしなんか味気ない。
金属製も同じく。
石膏は表面についた傷や汚れなどを表現する必要があり、これもコードで表現するのは過酷。木ならどうか。
年輪はある程度規則性があるので表現できるかもしれないということで角材の木目を描いてみることにした。準備
まずは各面にテクスチャを貼る準備。
各面のsublayerに加えたグラデーションの色指定をグレースケールから白のアルファでの表現に変更して、
各面のCALayer.contentsにCGImage
を入れられるようにする。CubeView.swiftlazy 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.swiftfinal 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.swiftprivate 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.swiftprivate 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.swiftfunc 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() }ランダムで生成しているので毎回異なる模様になるけど大体上面のイメージはこんな感じになる。
バームクーヘン。
前面のイメージ
前面にくる木目は上面のこの部分を引き伸ばしたものが垂直に落ちる形になる。
保持している年輪の情報から前面の木目を描く。
LumberTexture.swiftfunc 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() } ... }但し、人工物のように全ての年輪が並行/垂直に描かれるのは不自然なため、
CIFilter
のCITwirlDistortion
を使って少し歪める。LumberTexture.swiftfunc 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 }右側面のイメージ
ここの表現が非常に難しい。。
上面と前面の年輪の断面を同時に表現する必要があってさらに前面には歪みも設けているため、全てパスで表現すると相当複雑な計算が必要になると思われる。
知識もないし工数もないのでなんとか試行錯誤して誤魔化すことにした。まずは上面の断面である年輪を描く。
ここから続く模様を生成しなければならない。
上面イメージの右端1pxからタイリングして90°回転したイメージを作り出す。
LumberTexture.swiftprivate 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 }次に前面の断面である年輪を描く。
ここから続く模様を生成しなければならないが、歪めた影響でやや複雑。
こちらも同じくイメージの右端1pxからタイリングしてイメージを作り出す。
LumberTexture.swiftprivate 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 }
上断面に合わせると 前断面に合わせると 一見良さそうだが、前面の歪み部分と合ってこない。。 前面の歪みから続く部分はこちらで良さそうだが。。 3D的に考えると前断面は少しで、あとは基本 上断面模様になるはず?こんな感じ?やや不自然だがまあいいか。
上断面イメージにグラデーションマスクした前断面イメージを合成する。
LumberTexture.swiftfunc 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.swiftprivate 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 }木目の保存
木目の描画は毎回ランダムで別の幅や色太さになる。
気に入った木目を継続できるようにすれば作り込み時の比較にも一役買いそう。
Ring
構造体をCodableにする。LumberTexture.swiftfinal class LumberTexture { struct Ring: Codable { let distance: CGFloat let width: CGFloat let depth: CGFloat let colorComponents: [CGFloat] } ...年輪の情報を保持している部分に保存機能を付ける。
Ring
をCodableにしたため、JSONDecoder/JSONEncoderを使ってそのままUserDefaultsに読み書きする。LumberTexture.swiftprivate 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.swiftoverride func layoutSubviews() { super.layoutSubviews() ... updateTexture() addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(updateTextureAction))) }CubeView.swiftprivate 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.swiftfinal class LumberTexture { ... func updateRings() { UserDefaults.standard.removeObject(forKey: "SmoothRings") UserDefaults.standard.removeObject(forKey: "RoughRings") self.smoothRings = createSmoothRings() self.roughRings = createRoughRings() } }これでタップしない限り木目が更新されない形になる。
ハイライトの調整
--- 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グレースケールにして確認してみる。
grayscale.diffdiff --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) + } +}
明度の問題はなさそう。
もう少し木目のリアル感を上げ作り込みたい。木目のリアル感を向上させる
木目のベースとなる色の幅が単色なためか単調な印象があるのと、少し色が暗い感じがするので色数を増やす。
LumberTexture.swiftfinal 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.swiftfunc 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.swiftfunc 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.swiftlet roughRingColorComponents: [[CGFloat]] = [ [(176 / 255), (130 / 255), (71 / 255)], <- [(194 / 255), (158 / 255), (96 / 255)], ]
上面 前面 右側面
Before After
- 基本色が明るくなった
- 細かい年輪がより目立つようになった
- 木目の色に幅ができた
切断跡
木材をよく観察すると木の繊維とは別の加工する際についた傷が残っていることがある。
木の繊維に逆らう方向に切ることにより後が残ると思われる。
やや人工的に等間隔で描く。LumberTexture.swiftfunc 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() }上面の奥から手前に徐々に効果がなくなるように描画。
わかりやすく赤で描画するとこんな感じ。
Before After 効果は高くないが上面に色幅と新たな方向が加わった。
遊ぶ
タップして木目を切り替える
色情報の定義を分離する
色情報を外から入れられるようにすることでカスタム可能な作りにする。
構造体としてLumberColorSet
を定義して指定できるようにする。LumberColorSet.swiftstruct 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.swiftfinal 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.swiftstruct 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
いい感じの模様になるまでタップを繰り返す。なんかちょっと怖いかも。スイカっぽいし。
画像から色を抽出
RGBを定義するのが面倒なので画像から代表的な色を抽出するコードを書いてみた。
LumberColorSet.swiftinit?(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から追加された
CIFilter
のCIKMeans
を使用してイメージの代表色を抽出してみる。k-means法で代表色を抽出するアルゴリズムらしい。6色必要なので
"inputMeans"
に代表的な6色を与え、filter.outputImage
が 6 x 1で得られるような属性にする。
属性はこちらのURLを参考にした。LumberColorSet.swiftprivate 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.swiftprivate 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) }抽出した結果は以下の色になった。
#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
#010101
#560200
#035500
#545400
#000000
#000000
明度・彩度を弄った色
#804040
#800300
#058000
#808000
#804040
#804040
なんとなく近い色は取れたけどやはり意図しない補正がかかってしまう。
杉や赤松など他の木目イメージから色をサンプリングして適用してみたけど、
あまり良い結果が得られなかったので割愛する。。感想
コードで木目という有機的なものを描こうと思ったことはなかった。
規則性を見出すことでコードに落とし込める発見があるのと、よりリアルに近づけていくにはどういうイレギュラーを加えれば良いのか試行錯誤するのが楽しかった。
木の節はどこかに入れてみたかったが難しくて断念した。
- 投稿日:2020-12-16T11:08:10+09:00
【Swift 5】線を引く。
みさなんこんちには、@Zhalen(ツァーレン)です。
もうすぐクリスマスですね。このマスクばかり売れて人々が隔離され企業の悪い経営システムすらも浮き彫りになりそれだけでなく在宅でできることに関する産業の需要が増し以前とはだいぶ違った世の中になったこともさることながらただ徒らに時がすぎてゆく中、如何お過ごしでしょうか。
私は、虎視淡々と未来を見据えています。そしてとても空腹です。
このように任意の箇所に直線を引くメソッドを作ったので紹介します。
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) } } }ありがとうございました
- 投稿日:2020-12-16T09:59:43+09:00
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を利用していたのですが、この仕様上まともに動作させるほうが困難なので削除しました。現状、受け取った文字列が確実に全体を反映しているとわかる状況は「内部に区切り文字を含んでいない状況」のみです。区切り文字のリストが公開されていない以上、私が発見した以外にも区切り文字があるかもしれないので、この判定方法も潜在的に不具合の可能性を孕みます。
受け取っているテキストが正しいものなのか正しくないものなのかを判断することが出来ない点がかなり苦しいです。どうにかならないでしょうか。
また、この辺りの振る舞いはほとんどドキュメントが見つからず、気づいていない仕様がまだまだある可能性が高いです。今後何かわかったら追記します。
- 投稿日:2020-12-16T08:58:59+09:00
[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へ遷移、所持していない場合はアカウント作成を促す画面に遷移する模様。
- 投稿日:2020-12-16T05:36:07+09:00
【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 4mutating
値型では、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】型の種類〜列挙型〜最後までご覧いただきありがとうございました。