【iOS】画像を指で回転する方法
こんにちは、タケシです。
この記事ではiOSアプリにて「画像を指で回転する方法」について解説します。
現実世界ではモノを回して何かを操作することはよくあります。
福引の抽選器、DJのターンテーブル、昔ながらの黒電話などなど。
それらを模倣したUIの実装をする場合、この記事が役に立つでしょう。
途中で三角関数など数学的な知識が必要になる場面がありますが、なるべく分かりやすく解説します。
もちろん「やり方だけ知りたい!」という方もOKです。
では始めます。
目次
- 開発環境
- サンプルアプリ
- 下準備
- タップ座標から角度を調べる
- 「解説」度数とラジアン
- 「解説」三角関数
- 移動角度を調べる
- 角度からViewを回転させる
- transformから角度を調べる
- 終わりに
開発環境
解説は下記の環境で行います。
- Xcode10.1
- Swift4.2
サンプルアプリ
今回作成したサンプルアプリはこちらです。
ビルドをすると円の中に矢印がある画像が表示され、タップしたままスライドさせるとそれに合わせて画像が回転します。
GitHubのURLはこちらです。
https://github.com/SatoTakeshiX/SampleWheel
下準備
ではさっそく解説をしていきます。
独自のビュークラスを作成し、画像を表示できるようにします。
まず下準備としてWheelView
というUIView
を継承したクラスを作ります。
初期化する際にStoryboardなどのNibファイルとコードからとどちらも作成できるように
init(frame:)
とinit?(coder:)
を実装します。
@IBDesignable
public class WheelView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
}
今回のサンプルではView階層にUIImageView
を配置します。
WheelView
に対して上下左右いっぱいに広がるようにAuto Layoutを設定します。
setup
メソッドの実装は以下のとおりです。
// プロパティとしてimageViewを保持
private let imageView = UIImageView(frame: CGRect())
private func setup() {
// autolayoutを設定するため
imageView.translatesAutoresizingMaskIntoConstraints = false
// View階層に組み込む
self.addSubview(imageView)
// imageViewを上下左右いっぱいに制約をつける
imageView.topAnchor.constraint(equalToSystemSpacingBelow: self.topAnchor, multiplier: 0).isActive = true
imageView.leadingAnchor.constraint(equalToSystemSpacingAfter: self.leadingAnchor, multiplier: 0).isActive = true
imageView.trailingAnchor.constraint(equalToSystemSpacingAfter: self.trailingAnchor, multiplier: 0).isActive = true
imageView.bottomAnchor.constraint(equalToSystemSpacingBelow: self.bottomAnchor, multiplier: 0).isActive = true
}
Storyboard上で画像をコンテンツモードを変更できるようにimage
プロパティとcontentModeNumber
プロパティを追加し@IBInspectable
を追加します。
@IBInspectable var image: UIImage? {
didSet {
imageView.image = image
}
}
@IBInspectable var contentModeNumber: Int = 0 {
didSet {
guard let mode = UIView.ContentMode(rawValue: contentModeNumber) else { return }
imageView.contentMode = mode
}
}
これでStoryboard上でimage
とcontentModeNumber
を変更できるようになりました。
これで下準備は完了です。
タップ座標から角度を調べる
画像を回転するまえにユーザーがタップした座標から中心までの角度を調べる必要があります。
タップが開始されたイベントはtouchesBegan(_:, with:)
で取得出来ます。
開始直後のタップ座標と中心点の角度を保持するためdeltaAngle
プロパティを追加します。
また開始直後のimageView
のtransform
を保持するためstartTransform
プロパティを追加します。
// WheelView.swift
// タップ開始時のTransform
var startTransform: CGAffineTransform?
// タップ座標と中心点の角度
var deltaAngle: CGFloat = 0.0
// タッチ開始
override public func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// タップ座標と中心点の角度を作成
guard let targetAngle = makeDeltaAngle(touches: touches) else { return }
// deltaAngleをアップデート
deltaAngle = targetAngle
// 現状のimageViewのstransformを更新
startTransform = imageView.transform
}
独自に実装したmakeDeltaAngle
メソッドによってタップ座標と中心点の角度を作成して、deltaAngle
プロパティに
makeDeltaAngle
メソッドをみてみます。
// WheelView.swift
private func makeDeltaAngle(touches: Set<UITouch>) -> CGFloat? {
guard let touch = touches.first else { return nil }
// 座標を計算するCoordinateManagerを作成
let manager = CoordinateManager()
// タップ座標を作成
let touchPoint = touch.location(in: self)
// 中心点を作成
let center = CGPoint(x: imageView.bounds.width/2, y: imageView.bounds.height/2)
// タップ座標と中心距離を作成
let dist = manager.calculateDistance(center: center, point: touchPoint)
// タッチ範囲外
// 中心に近すぎると回転が飛び跳ねるような動きをするのである程度近いなら無視する
if manager.isIgnoreRange(distance: dist, size: imageView.bounds.size) {
print("ignoring tap \(touchPoint.x), \(touchPoint.y)")
return nil
}
// タップ座標と中心点の座標を返却
return manager.makeDeltaAngle(targetPoint: touchPoint, center: imageView.center)
}
コメントで書いたとおり、タップ座標と中心点を作成します。
タップ座標と中心点の距離が近すぎると急に画像がくるっと回るような動きをする場合があります。
それを防ぐために2点の距離を測り近すぎる場合は無視します。
距離を測る実装はこちらです。
// WheelView.swift
// タップ座標と中心距離を作成
let dist = manager.calculateDistance(center: center, point: touchPoint)
距離を測るためにはピタゴラスの定理を利用します。
図で説明するとこうなります。
コードは以下のようになります。
// CoordinateManager.swift
func calculateDistance(center: CGPoint, point: CGPoint) -> CGFloat {
let dx = point.x - center.x
let dy = point.y - center.y
return CGFloat(sqrt(dx*dx + dy*dy)) //ピタゴラスの定理より
}
距離が近すぎると無視する実装はこちら。
// WheelView.swift
// タッチ範囲外
// 中心に近すぎると回転が飛び跳ねるような動きをするのである程度近いなら無視する
if manager.isIgnoreRange(distance: dist, size: imageView.bounds.size) {
print("ignoring tap \(touchPoint.x), \(touchPoint.y)")
return nil
}
isIgnoreRange
メソッドは以下の通りです。
imageView
の大きさの1/10以下の距離または同じ大きさ以上の距離なら無視する実装です。
// CoordinateManager.swift
func isIgnoreRange(distance: CGFloat, size: CGSize) -> Bool {
if (distance < size.width/10 || size.width/2 < distance) {
return true
}
if (distance < size.height/10 || size.height/2 < distance) {
return true
}
return false
}
いよいよタップ座標と中心点の角度を作成します。
WheelView
のmakeDeltaAngle(touches:)
メソッドの最後で角度を作成しています。
// WheelView.swift
return manager.makeDeltaAngle(targetPoint: touchPoint, center: imageView.center)
CoordinateManager
の実装は次のとおりです。
中心点を座標の(0, 0)にそろえた後にatan2
関数でタップ座標と中心点の角度(数学用語でタップ座標と中心点の偏角と言うそうです)を返却します。
// CoordinateManager.swift
func makeDeltaAngle(targetPoint: CGPoint, center: CGPoint) -> CGFloat {
// 中心点を座標の(0, 0)に揃える
let dx = targetPoint.x - center.x
let dy = targetPoint.y - center.y
// 座標と中心の角度を返却
return atan2(dy, dx)
}
atan2
関数とは何でしょうか?
それを理解するためには三角関数のおさらいが必要です。
またViewの回転の角度の単位はラジアンというもので普段皆さんが使っている「〇°」という単位は使用しません。
次の項目でラジアンと三角関数について解説します。
「解説いらないよ」という方は「移動角度を調べる」まで行きましょう。
「解説」度数とラジアン
普段角度と聞いて「〇°」と表すのは「度数法」といいます。
定義は「円周を360等分した弧の中心に対する角度」とされています。
一方でラジアンは「弧度法」という角度を表す単位で「1ラジアンは円の半径の長さに等しい弧に対する中心角の大きさ」と定義されます。単位は「rad」です。
弧度法は円弧をもとに角度を表現します。
数学や物理学の世界では度数法よりも弧度法、ラジアンを使うのが多いそうです。
理由はwikiによると度数法を使うよりも計算の手間が減るからとのことです。
ラジアンと度数の変換コードは以下のように実装できます。
fileprivate extension Double {
// 度数からラジアンへ
var toRadians: Double { return self * .pi / 180 }
// ラジアンから度数へ
var toDegrees: Double { return self * 180 / .pi }
}
続いて三角関数の解説をします。
「解説」三角関数
高校数学で習う三角関数、サイン、コサイン、タンジェントをもう一度定義をおさらいしましょう。
サイン、コサイン、タンジェントは直角三角形の三辺の比を表します。
wikiの定義を抜粋すると
∠C を直角とする直角三角形 ABC において、それぞれの辺の長さを AB = h, BC = a, CA = b と表す(図を参照)。
∠A = θ に対して三角形の辺の比 h : a : b が決まることから、
sinθ = a/h
cosθ = b/h
tanθ = a/b = sinθ/cosθ
の値が定まるとしています。
図
さて、今回の例で当てはめます。
今求めたい値は「座標から中心までの角度」です。
一方touchesBegan
メソッドから取得できた情報は「タップした座標」です。
図の例で例えるとa
とb
がわかっている状態です。
ならば使用するのはtanθ
ということになります。(tanθ = a/bなので)
しかし、tanθ
がわかっても角度θがわかりません。
角度θを求めるにはタンジェントの逆三角関数arctangent関数を使います。
逆三角関数については私も理解が追いついていないのでwikiの紹介だけにとどめます。
逆三角関数 - Wikipedia
逆関数の意味はこちらがわかりやすいです。
逆写像 - Wikipedia
swiftを含む様々なプログラミング言語にはatan
関数とatan2(y,x)
関数が定義されています。
atan(x) は、tanθ=x となるような θ を計算します。
atan2(y,x) は、xy 直交座標における (x,y) と中心点の角度を計算します。
swiftのatan2関数は第一引数がy座標です。第二引数がx座標です。
つまりatan2(y,x)
関数を使うことで座標から中心点の角度を求めることが出来ます。
やっと角度がわかりました。続いて移動座標の角度を調べる処理に行きます。
移動角度を調べる
atan2(y,x)
関数を使うことで座標から中心点の角度がわかりました。
それではタップしたまま指をスライドした場合に移動角度を求める処理を実装します。
指をスライドしたイベントはUIView
のtouchesMoved(_:, with:)
メソッドをオーバーライドすることで実装出来ます。
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
// タッチされた座標をもとめる
guard let targetAngle = makeDeltaAngle(touches: touches) else { return }
// タッチが開始された
let angleDifference = deltaAngle - targetAngle
imageView.transform = startTransform?.rotated(by: -angleDifference) ?? CGAffineTransform.identity
}
touchesMoved
メソッドからタッチされた座標を求めます。
touchesBegan
メソッドで保持していたdeltaAngle
プロパティと求めた座標targetAngle
の差を求めることで移動された角度がわかります。
あとはその角度で回転をさせます。
imageView.transform = startTransform?.rotated(by: -angleDifference) ?? CGAffineTransform.identity
これで指をスライドさせて画像を回転できるようになります!
transformから角度を調べる
回した後に角度が何度になっていたかがわかると便利です。
デリゲートで通知できるようにしましょう。
どのくらい回転したかを通知するためにRotaryProtocol
というプロトコルを定義してupdatedRagianAngle
メソッドを適応させるようにします。
public protocol RotaryProtocol: class {
func updatedRagianAngle(wheelView: WheelView, angle: CGFloat)
}
WheelView
でRotaryProtocol
プロトコルを適応したdelegate
プロパティを定義します。
weak public var delegate: RotaryProtocol?
タッチの最後のイベントを検知するにはtouchesEnded(_:, with)
メソッドをオーバーライドします。
override public func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
// 回転をtransformから算出
var angle = atan2(imageView.transform.b, imageView.transform.a)
// ラジアン範囲を -.pi < Θ < pi から 0 < Θ < 2 piに変更
if angle < 0 {
angle += 2 * .pi
}
delegate?.updatedRagianAngle(wheelView: self, angle: angle)
}
回転をtransformから算出するには
var angle = atan2(imageView.transform.b, imageView.transform.a)
を行います。
ここのangle
変数は範囲が-.pi < Θ < piなので、0 < Θ < 2piに変更するため、マイナス値を補正します。
if angle < 0 {
angle += 2 * .pi
}
最後にデリゲートメソッドを実行します。
delegate?.updatedRagianAngle(wheelView: self, angle: angle)
ViewController側はどのような実装になるでしょうか?
今回はこのようにしました。
class ViewController: UIViewController {
@IBOutlet weak var wheelView: WheelView!
@IBOutlet weak var digreeLabel: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
wheelView.delegate = self
}
}
extension ViewController: RotaryProtocol {
func updatedRagianAngle(wheelView: WheelView, angle: CGFloat) {
print("radian: \(angle)")
let digree = angle * 180 / .pi
print("digree: \(digree)")
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 1
formatter.positiveFormat = "0.0" // "0.#" -> 0パディングしない
formatter.roundingMode = .halfUp // 四捨五入 // .floor -> 切り捨て
let digreeString = formatter.string(for: digree) ?? ""
digreeLabel.text = "角度: \(digreeString)°"
}
}
RotaryProtocolに適応し、返却されるラジアン値を度数に変換します。
そして、ラベルに表示をします。
終わりに
画像を指で回転させる方法について解説しました。
まとめると以下のようになります。
- タップの座標と中心点の角度は
atan2(y, x)
で求める - touchesMovedでタップ開始時との角度の差分を計算、反映
- Viewの回転角度は
atan2(view.transform.b, view.transform.a)
で取得可能 - 指を離した時に回転角度をデリゲートで通知
この記事が皆さまの役に立つと幸いです。