iOS, Swift, Tips

【iOS】画像を指で回転する方法

こんにちは、タケシです。
この記事ではiOSアプリにて「画像を指で回転する方法」について解説します。

現実世界ではモノを回して何かを操作することはよくあります。
福引の抽選器、DJのターンテーブル、昔ながらの黒電話などなど。

それらを模倣したUIの実装をする場合、この記事が役に立つでしょう。
途中で三角関数など数学的な知識が必要になる場面がありますが、なるべく分かりやすく解説します。
もちろん「やり方だけ知りたい!」という方もOKです。
では始めます。

目次

  1. 開発環境
  2. サンプルアプリ
  3. 下準備
  4. タップ座標から角度を調べる
  5. 「解説」度数とラジアン
  6. 「解説」三角関数
  7. 移動角度を調べる
  8. 角度からViewを回転させる
  9. transformから角度を調べる
  10. 終わりに

開発環境

解説は下記の環境で行います。

  • Xcode10.1
  • Swift4.2

サンプルアプリ

今回作成したサンプルアプリはこちらです。
ビルドをすると円の中に矢印がある画像が表示され、タップしたままスライドさせるとそれに合わせて画像が回転します。

screen

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上でimagecontentModeNumberを変更できるようになりました。

----------2018-12-12-14.35.58

これで下準備は完了です。

タップ座標から角度を調べる

画像を回転するまえにユーザーがタップした座標から中心までの角度を調べる必要があります。

fig1

タップが開始されたイベントはtouchesBegan(_:, with:)で取得出来ます。
開始直後のタップ座標と中心点の角度を保持するためdeltaAngleプロパティを追加します。
また開始直後のimageViewtransformを保持するため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)

距離を測るためにはピタゴラスの定理を利用します。
図で説明するとこうなります。

fig2-1

コードは以下のようになります。

// 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
}

いよいよタップ座標と中心点の角度を作成します。
WheelViewmakeDeltaAngle(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等分した弧の中心に対する角度」とされています。

度数法 wiki

一方でラジアンは「弧度法」という角度を表す単位で「1ラジアンは円の半径の長さに等しい弧に対する中心角の大きさ」と定義されます。単位は「rad」です。

弧度法

弧度法は円弧をもとに角度を表現します。

fig3

数学や物理学の世界では度数法よりも弧度法、ラジアンを使うのが多いそうです。
理由はwikiによると度数法を使うよりも計算の手間が減るからとのことです。

ラジアン wiki

ラジアンと度数の変換コードは以下のように実装できます。

fileprivate extension Double {
    // 度数からラジアンへ
    var toRadians: Double { return self * .pi / 180 }
    // ラジアンから度数へ
    var toDegrees: Double { return self * 180 / .pi }
}

続いて三角関数の解説をします。

「解説」三角関数

高校数学で習う三角関数、サイン、コサイン、タンジェントをもう一度定義をおさらいしましょう。

三角関数 wiki

サイン、コサイン、タンジェントは直角三角形の三辺の比を表します。
wikiの定義を抜粋すると

∠C を直角とする直角三角形 ABC において、それぞれの辺の長さを AB = h, BC = a, CA = b と表す(図を参照)。
∠A = θ に対して三角形の辺の比 h : a : b が決まることから、

sinθ = a/h
cosθ = b/h
tanθ = a/b = sinθ/cosθ

の値が定まるとしています。


fig4

さて、今回の例で当てはめます。
今求めたい値は「座標から中心までの角度」です。
一方touchesBeganメソッドから取得できた情報は「タップした座標」です。
図の例で例えるとabがわかっている状態です。
ならば使用するのはtanθということになります。(tanθ = a/bなので)

しかし、tanθがわかっても角度θがわかりません。
角度θを求めるにはタンジェントの逆三角関数arctangent関数を使います。

逆三角関数については私も理解が追いついていないのでwikiの紹介だけにとどめます。
逆三角関数 - Wikipedia
逆関数の意味はこちらがわかりやすいです。
逆写像 - Wikipedia

swiftを含む様々なプログラミング言語にはatan関数とatan2(y,x)関数が定義されています。

atan(x) は、tanθ=x となるような θ を計算します。
atan2(y,x) は、xy 直交座標における (x,y) と中心点の角度を計算します。

fig5

swiftのatan2関数は第一引数がy座標です。第二引数がx座標です。

つまりatan2(y,x)関数を使うことで座標から中心点の角度を求めることが出来ます。

やっと角度がわかりました。続いて移動座標の角度を調べる処理に行きます。

移動角度を調べる

atan2(y,x)関数を使うことで座標から中心点の角度がわかりました。
それではタップしたまま指をスライドした場合に移動角度を求める処理を実装します。

指をスライドしたイベントはUIViewtouchesMoved(_:, 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の差を求めることで移動された角度がわかります。

fig6

あとはその角度で回転をさせます。

imageView.transform = startTransform?.rotated(by: -angleDifference) ?? CGAffineTransform.identity

これで指をスライドさせて画像を回転できるようになります!

transformから角度を調べる

回した後に角度が何度になっていたかがわかると便利です。
デリゲートで通知できるようにしましょう。
どのくらい回転したかを通知するためにRotaryProtocolというプロトコルを定義してupdatedRagianAngleメソッドを適応させるようにします。

public protocol RotaryProtocol: class {
    func updatedRagianAngle(wheelView: WheelView, angle: CGFloat)
}

WheelViewRotaryProtocolプロトコルを適応した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)で取得可能
  • 指を離した時に回転角度をデリゲートで通知

この記事が皆さまの役に立つと幸いです。

参考

Author image

About Sato Takeshi

  • Tokyo, Japan