今回作成したアプリはサブディスプレイを使ったお絵描きアプリです。
WBというアプリ名で公開してます。
iPadとサブディスプレイを接続すると、サブディスプレイのキャンバスを手元のiPadで操作をすることができます。 線の描き込み・キャンバスの拡大縮小・移動などです。
このアプリにて外部ディスプレイをホワイトボードとして使っています。
「ホワイトボードアプリ・お絵描きアプリはたくさんあるけど外部ディスプレイ繋いだときはミラーリングだよね。」
「外部ディスプレイを使ったアプリって少ないよね? YouTubeのアプリでさえミラーリングだもんね」
「2画面使うアプリって少ないし、知見を得られたら強いし面白そうだね!」
という話から”研修がてら作ってみますか!”となりました。
サブディスプレイを使うアプリの実装で、自分が特に詰まった箇所をメモとして残します。
おかしい実装をしている箇所が多いとは思いますがあたたかい目で見守ってくださいm(_ _)m
コードレビューできる人周りにいないんです。。。
記事の内容
今回 "特に" 詰まった部分を記事にまとめます。
大きく分けて二つの構成です。
前提
-
実装で詰まった部分の備忘録のような記事です。省略して書いている部分が多いです。
-
独学で実装しているため、記事へのアウトプットに癖があるとは思います。研修がてらの実装なのでいろいろ汚いです。Viewのインスタンスの作りかたとか。。。。
-
storyboard, xib は使わず、コードでの実装です。
-
一つの ViewController が iPadのView ・ サブディスプレイのView ・ツールバーのView を知っています。
-
線の描画部分の実装は、Sketchというライブラリを使用しています。 MIT Licenseで公開されています。
こちらで紹介されています。
SketchView というインスタンスを View に突っ込むだけでお絵描きができるすごいライブラリです!✨
アプリの機能
機能を簡単に紹介します。
- お絵描きができるキャンバス
- iPad
- サブディスプレイ
それぞれ外部ライブラリの SketchView を SubView に持つカスタムビューを実装しています。
- ツールバー
- ペンモード
- 消しゴムモード
- 戻るボタン
- 進むボタン
- クリアボタン
- ふせん
- ペンの色
- ヘルプ
- ペンボタンもしくは消しゴムボタンを長押しで線の太さ変更
UIButton を7個持つカスタムビューを実装しています。
サブディスプレイに別の画面を表示する方法
アラートを表示するときによく使う、UIWindowを使う方法で実装します。
UIWindowはscreenというUIScreen型のプロパティを持っているので、サブディスプレイのスクリーンを代入します。
1. UIWindow, UIScreen
端末のスクリーンとサブディスプレイのスクリーン
UIScreen クラスは UIScreen.main プロパティで端末の画面を持ちます。今回は iPad が持つ画面です。
また、 UIScreen クラスは UIScreen.screens プロパティに端末の画面と接続されている画面の情報を配列で持っています。
UIScreen
class var main: UIScreen
class var screens: [UIScreen]
UIScreen.main は UIScreen.screens[0] で取得可能です。
サブディスプレイは接続されると、接続された数だけ screens[1] 以降に追加されていきます。
今回はiPadが持つ画面の screens[0] と、サブディスプレイが持つ画面の screens[1] を使います。
サブディスプレイに端末の画面とは別の画面を表示するためには
- UIWindow のインスタンスを作成し、 UIWindow.screen プロパティに UIScreen.screens[1] を代入します。
- あとはサブディスプレイの UIWindow に自分でレイアウトした View を addSubView でOKです。
```swift
//サブディスプレイ用のWindow
var subDisplayWindow: UIWindow?
//サブディスプレイに表示するView
//カスタムビューを用意しても大丈夫です!
var subDisplayView: UIView!
func addSubDisplayWindow() {
if UIScreen.screens.count <= 1 {
return
}
print("connected sub display")
//サブディスプレイのスクリーンを取得
var subScreen = UIScreen.screens[1]
subDisplayWindow = UIWindow()
subDisplayWindow?.screen = subScreen
subDisplayWindow?.frame = subScreen.bounds
subDisplayWindow?.backgroundColor = .white
subDisplayView = UIView()
subDisplayView.frame = subScreen.bounds
subDisplayView.center = (subDisplayWindow?.center)!
subDisplayWindow?.addSubview(subDisplayView)
}
サブディスプレイの接続が解除された場合は subDisplayWindow に nil を代入します。
func removeSubDisplayWindow() {
print("disconnected sub display")
subDisplayWindow = nil
}
UIScreen.didConnectNotification, didDisconnectNotification
サブディスプレイを接続、接続解除した際、通知を受け取ることができます。
接続の通知で UIWindow を生成。
接続解除の通知で UIWindow を破棄。
func setupNotificationHandler() {
NotificationCenter.default.addObserver(self, selector: #selector(addSubDisplayWindow), name: UIScreen.didConnectNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(removeSubDisplayWindow), name: UIScreen.didDisconnectNotification, object: nil)
}
ViewControllerにまとめる
ViewControllerにまとめるとこうなります。
このコードはコピぺしてみると実際に動かすことができます。
class ViewController: UIViewController {
//サブディスプレイ用のWindow
var subDisplayWindow: UIWindow?
//サブディスプレイに表示するView
var subDisplayView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = .white
setupNotificationHandler()
}
//サブディスプレイの接続と接続解除それぞれの通知でのハンドラー
func setupNotificationHandler() {
NotificationCenter.default.addObserver(self, selector: #selector(addSubDisplayWindow), name: UIScreen.didConnectNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(removeSubDisplayWindow), name: UIScreen.didDisconnectNotification, object: nil)
}
//接続の処理
@objc func addSubDisplayWindow() {
if UIScreen.screens.count <= 1 {
return
}
print("connected sub display")
//サブディスプレイのスクリーンを取得
let subScreen = UIScreen.screens[1]
subDisplayWindow = UIWindow()
subDisplayWindow?.screen = subScreen
subDisplayWindow?.frame = subScreen.bounds
subDisplayWindow?.backgroundColor = .white
subDisplayWindow?.isHidden = false
subDisplayView = UIView()
subDisplayView.frame = subScreen.bounds
subDisplayView.backgroundColor = .purple
subDisplayView.center = (subDisplayWindow?.center)!
subDisplayWindow?.addSubview(subDisplayView)
}
//接続解除の処理
@objc func removeSubDisplayWindow() {
print("disconnected sub display")
subDisplayWindow = nil
}
}
Simulatorで実際に動かしてみたの図
Simulatorの Hardware -> External Displays で好きなサイズの追加ディスプレイを表示できます。
別の View が表示されていることがわかります。
お世話になったサイト
iOS マルチディスプレイ Swift 4
[iOS] メインWindowの上に新しくUIWindowを作成する方法
UIWindow UIKit | AppleDeveloper Documentation
Delegateを使う
Swiftでの開発で最初に詰まる人も多いであろうDelegateで、私も詰まりました。
実装したかったこと。
- iPadの画面下部、各種ボタンのアクションをViewControllerで処理
- iPadのキャンバス上でのタップ座標を、サブディスプレイのキャンバスに渡す。
実際の画面はこのような感じです。
Delegateの使い方そのものを理解することに時間がかかりました
Delegateって内部で何が起こっているの?Delegateってどういう意味????等はわかりやすい記事がたくさんあるので、最後の方にDelegateの実装の際お世話になったサイトへの参照URLを貼ります。
実装する時どのように書いたのかという部分を記事に載せます。
ツールバーのアクションをViewControllerに実装
- 画面下にあるツールバーのボタンをタップした時、 iPad 上のカスタムビューとサブディスプレイ上のカスタムビューを操作するために ViewController へ処理を委譲する。
今回例として実装するのは
**「ツールバーのペンボタンをタップしたときのアクションをViewControllerに委譲する。」 **
です。
iPadの画面下にあるツールバーを ToolBarView という UIView を継承したクラスで定義しています。
実装作業順に並べます。
-
ToolBarView
-
委譲したい関数を定義したプロトコルを用意する。
-
プロトコルに準拠した変数を ToolBarView に持たせる。
-
ボタンのアクションで、変数が持つプロトコルに準拠するメソッドを実行する。
-
viewController
-
プロトコルを継承する。
-
継承した関数の中身を書く。
-
ボタンの変数をselfにする。
1-1. 委譲したい関数を定義したプロトコルを用意する
protocol ToolBarViewDelegate {
func tapPenButton()
...
}
今回の場合、ペンボタンがタップされたときの処理を ViewController に委譲したいので tapPenButton() という関数を含んだ ToolBarViewDelegate という プロトコル を用意しています。
1-2. プロトコルに準拠した変数をToolBarViewに持たせる。
class ToolBarView: UIView {
var delegate: ToolBarViewDelegate?
...
}
先ほど定義した ToolBarViewDelegate というプロトコルに準拠した delegate という変数を定義しています。
初期値はnilなのでオプショナル型です。
delegate という単語が出てきましたね。
この変数を使って処理を委譲します。
1-3. ボタンのアクション内にてプロトコルで定義したメソッドを使用する。
class ToolBarView: UIView {
var delegate: ToolBarViewDelegate?
...
@objc func penButtonAction() {
delegate?.tapPenButton()
}
...
}
ボタンタップのアクションで、 tapPenButton() メソッドを実行します。
delegate には委譲先であるViewControllerが代入されます。
2-1. プロトコルを継承する
class ViewController: UIViewController, ToolBarViewDelegate {
...
}
継承するときは
class "クラス名を定義": (コロン) "親クラス名", (カンマ) プロトコル名, ...
と書きます。
クラスは一つしか継承できませんが、プロトコルはカンマ区切りで書くことで複数継承できます。
class クラス名: 親クラス, プロトコル1, ... {}
2-2. 継承したプロトコルが持つ関数のメソッドが、実際に行う処理を書く
class ViewController: UIViewController, ToolBarViewDelegate {
...
func tapPenButton() {
print("ToolBariew.penButton Taped!")
//penButton がタップされた時に行う処理をここに記述
...
}
...
}
ViewControllerが ToolBarViewDelegate を継承したので
tapPenButton() をViewControllerに実装しないと Xcode「プロトコルに準拠してないよ!」 と怒られるのでサジェスチョン通りfixしましょう
ViewController.tapPenButton() 内に実装された処理がペンボタンタップ時に実行されます。
2-3. ToolBarViewのdelegate変数にselfを代入
class ViewController: UIViewController, ToolBarViewDelegate {
let toolBarView = ToolBarView()
toolBarView.delegate = self
...
func tapPenButton() {
//ViewControllerのSubViewを操作する処理を実行
print("ToolBariew.penButton Taped!")
...
}
...
}
delegateの通知先をViewController自身にしています。
ToolBarViewDelegate というプロトコルに準拠したオブジェクトである ViewController のインスタンスを代入することで、
ToolBarView は delegate という変数を通して ViewController に処理を委譲できるようになります。
わかりやすい言葉にするのがすごく難しいポイントです。
【参考になった記事: 【Swift】hogehoge.delegate = self は何をしているのか。】
全容
protocol ToolBarViewDelegate {
func tapPenButton()
...
}
class ToolBarView: UIView {
var delegate: ToolBarViewDelegate?
override init(frame rect: CGRect) {
super.init(rect)
let penButton = UIButton()
penButton.addTarget(self, action: #selector(penButtonAction), for: .touchUpInside)
...
}
...
@objc func penButtonAction() {
delegate?.tapPenButton()
}
...
}
class ViewController: UIViewController, ToolBarViewDelegate {
let toolBarView = ToolBarView()
toolBarView.delegate = self
...
func tapPenButton() {
// ToolBarViewに実装されているボタンをタップした時の内容を、ViewControllerに実装できる(委譲できる)。
print("ToolBariew.penButton Taped!")
}
}
iPadとサブディスプレイ
今回例として実装するのは
「iPad へのタッチを検知」
「iPad 上のキャンパスでのタッチ座標をサブディスプレイのキャンバスに渡す処理」
です。
iPadの画面タッチを検知する方法は大きく分けて二つあります。
- gestureRecognizerを実装する。
- UIView が持っている touchesBegan, Moved, Ended をそれぞれ override する
今回はtouchesBegan, touchesMoved, touchesEnded を override しています。
iPad上でのカスタムビューの touchesBegan, touchesMoved, touchesEnded それぞれでDelegateを利用し ViewController に座標を一度渡し、ViewController からサブディスプレイ上のカスタムビューにタッチ座標を渡しています。
iPad上のカスタムビューを MainView という UIView を継承したクラスで定義しています。
サブディスプレイ上のカスタムビューを SecondView という UIView を継承したクラスで定義しています。
外部ライブラリの SketchView にそのまま Set<UITouch>, UIEvent? と タッチされた View を渡すために、ライブラリの touchesBegan, Moved, Ended の引数部分に追加で in view: UIView という引数を取るよう書き加えています。
public func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?, in view: UIView) {
...
}
public func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?, in view: UIView) {
...
}
public func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?, in view: UIView) {
...
}
MainViewで行っていること。
- MainView から ViewController へ委譲したい関数を持つプロトコルを定義する。
- プロトコルに準拠した変数を MainView に定義する。
- iPad のタッチイベントからそれぞれプロトコルに準拠するメソッドを実行する。
実際の処理
- mainView が持つ SketchView にタッチイベントの引数を渡す。
- ViewController に delegate で 処理を委譲
protocol MainViewDelegate {
func mainViewTouchesBegan(_ touches: Set<UITouch>, with event: UIEvent?, in view: UIView)
func mainViewTouchesMoved(_ touches: Set<UITouch>, with event: UIEvent?, in view: UIView)
func mainViewTouchesEnded(_ touches: Set<UITouch>, with event: UIEvent?, in view: UIView)
}
class MainView: UIView {
var delegate: MainViewDelegate?
var sketchView: SketchView!
...
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?, in view: UIView) {
sketchView.touchesBegan(touches, with: event, in: self)
delegate?.mainViewTouchesBegan(touches, with: event, in: self)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?, in view: UIView) {
sketchView.touchesMoved(touches, with: event, in: self)
delegate?.mainViewTouchesMoved(touches, with: event, in: self)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?, in view: UIView) {
sketchView.touchesEnded(touches, with: event, in: self)
delegate?.mainViewTouchesEnded(touches, with: event, in: self)
}
...
}
SecondViewで行っていること。
- ViewControllerが実行する関数を定義する。
実際の処理
- ViewController から受け取ったタッチイベントの引数を secondView が持つ sketchView に渡す。
class SecondView: UIView {
var sketchView: SketchView!
...
func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?, in view: UIView) {
sketchView.touchesBegan(touches, with: event, in: self)
}
func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?, in view: UIView) {
sketchView.touchesMoved(touches, with: event, in: self)
}
func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?, in view: UIView) {
sketchView.touchesEnded(touches, with: event, in: self)
}
...
}
ViewControllerで行っていること。
- MainViewで定義したプロトコルを継承する。
- 継承したプロトコルが持つメソッドの中身を書く。
- mainView.delegate に self を代入する。
実際の処理
- mainView から受け取ったタッチイベントの引数を ViewController 自身が持つ secondView に渡す。
class ViewController: UIViewController, MainViewDelegate {
var mainView: MainView!
var secondView: SecondView!
...
override func viewDidLoad() {
super.viewDidLoad()
secondView = SecondView()
mainView = MainView()
mainView.delegate = self
...
}
func mainViewTouchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
secondView.touchesBegan(touches, with: event, in: view)
...
}
func mainViewTouchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
secondView.touchesBegan(touches, with: event, in: view)
...
}
func mainViewTouchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
secondView.touchesBegan(touches, with: event, in: view)
...
}
}
それぞれ引数の (_ touches: Set, with event: UIEvent?, in view: UIView) をそのまま渡しています。
これで、iPad 上のキャンバスで描いたタッチの座標をサブディスプレイのキャンバスに渡すことができます。
お世話になったサイト
【Swift】hogehoge.delegate = self は何をしているのか。
【swift】イラストで分かる!具体的なDelegateの使い方。
[swift]4ステップで実装できるdelegate
まとめ
他にも、描いた線(Path)を配列で持つため、配列の出し入れに関して少しライブラリを書きくわえました。
実際にアプリを作ると色々な知見が自身に蓄積します。
今回他に触った箇所は
- UIBezierPath について
- UIPanGestureRecognizer, UIPinchGestureRecognizer について
- AutoLayout の実装
- UIView.animation の実装
などです。
いつか備忘録としてまとめたいです。
わかりづらいところや、間違った学び方をしているところが多々あるかもしれません。
ご指摘等ありましたらよろしくお願いします!