中学数学をちょっとだけ使ってAuto Layoutで円形のViewの円周上に別のViewを配置するときの制約の計算式を導き出す

概要

理科大17卒で、現在は本職でiOSエンジニアをしているimaizumeです。
今年の5月にiOS開発にJOINしてから半年以上SwiftやInterface Builderと向き合って過ごしてきました。

日々の業務の中で、特殊なデザインやUIの要件をこなす中で、これはネットに無いんじゃないかというような知見をいくか得てきました。
今回はその中でAuto Layoutを使い円形のUIViewを配置した上でその円周上に別のViewを配置する方法を紹介します。
実はこの配置ではちょっとした 中学数学を使って比率を計算する必要があります 
社会人になって、まさかこんなところで中学数学のお世話になるとは自分もびっくりしましたが、理科大Advent Calendarですし、ちょっと数学の話も入れてみたかったのもあって書くことにしました :triangular_ruler:

イメージとしてはこんな感じです。

fig1.png

例えばですが

  • 円形のプロフィール写真の右下に編集ボタンがある :arrow_backward: 今回はこの例を使います
  • 円形のゲージ(メーター)のようなViewがあったときのサブメーター
  • 惑星の軌道 :question:
  • お団子 :question:

などを表示するViewを作るのに使える配置かと思います。

なぜAuto Layoutか

極端な話固定サイズであれば円周上の点は直接座標を計算して配置することも可能です。

しかし固定サイズは各端末にフィットしたサイズでUIを再現できないため、自分のチームでもごく一部の例外を除き全てのViewを画面幅あるいはそれに準ずる比率による制約で配置することをルールとしています
例えば固定値の場合iPhone SE基準で組んだレイアウトは必ずそれ以上の大きさの端末では小さく表示されてしまいますし、その逆もしかりです。
自分も実際に上記のようなレイアウトを様々なiOSデバイスでズレなく表示するために、Auto Layoutを使用しました。

考え方

さて本題に戻ります。
今回は前述の通り円形のプロフィール写真が中央に配置され、その右下の円周上に編集ボタンが付くようなUIを構築していきます。
ここで最初に構築使用するViewに名前をつけておきます。

fig2.png

今回登場するViewは全部で3つです (細かい点を言うと、ViewBはアイコンのバックグラウンドだけなので別途UIImageViewをSubViewに置く必要はありますが本質ではないので省略します)

  • [ViewA] 円形のプロフィール写真(UIImageView)
  • [ViewB] 右下の編集ボタン(UIView)
  • [ViewC] 編集ボタン配置のためのダミーView(UIView)

またいくつか比率に対する条件も予め提示しておきます。

  1. ViewA.width : 画面.width = 3 : 4
  2. ViewB.width : ViewA.width = 1 : 10
  3. ViewBの配置角度 = X軸正の方向を0度として-45度(右下)

実際に自分で作る時は好きなように条件を変えてもらって構いませんが、 1:1の比率指定は基本崩さない方が良いと思います(円形のUIViewを前提としているため)。
縦方向の配置は 横幅 = デバイス.width かつ 1 : 1 の正方形のViewをAの親に置いて、縦中央配置をしています(ViewAのポジションの問題も本質ではないため省略、ViewAはどこに置いても他のViewは勝手についてくるはずです)。

以上の条件をまとめると下の方な画面のイメージなります。

fig3.png

制約の付け方

ViewAへの制約

では早速制約をつけていきます。
まず前述の

  • ViewA.width : 画面.width = 3 : 4
  • 正方形制約 (Aspect Ratio = 1 : 1)

を設定します(これもView Aの大きさ決めなので任意の比率でOK)。
また前述のように、適宜親に1つ正方形のViewを挟んで、デバイスの上端や左右との余白を取ったりします。
これでAに対しての制約付けは終わりです、簡単でしたね :rolling_eyes:

ViewBへの制約

次に右下に配置するViewBですが、こちらは大きさと比率についてだけ制約をつけてしまいます。

  • ViewB.width : ViewA.width = 1 : 10
  • 正方形制約 (Aspect Ratio = 1 : 1)

ポジション制約については次のView Cで見ていきます。

ViewCへの制約

その後に登場するのがViewCです。
ViewCはViewBのポジションを決めるためのダミーViewになります。
したがって制約としては

  • View Aに対して左右上下中央揃え (Horizontally/Vertically Center)
  • 正方形制約 (Aspect Ratio = 1 : 1)
  • View B.leading = View C.trailing (右端にくっつく)
  • View B.top = View C.bottom (下端にくっつく)

で配置します。
これでViewCのポジションは決まったので、あとは大きさの制約を与えれば全ての制約が満たされるはずです。
ここでようやく本題に入ります。

View C.width : View A.width = :question: : :question:

今各Viewはこのような配置関係にあります。
ViewAの大きさに合わせてViewBがうまく円周に乗るようにしたいので、ViewCの大きさもView Aに対する比で与える必要があります。
ではその比はいくつになるでしょうか :question:

ここでようやく中学数学の登場です。
説明の都合上、ViewAの半径 (実際のUIViewのwidthの半分) を一旦 $R$ とおきます。
また同様にしてViewBの半径も $r$ とします。

ただ、これだけはまだViewCの大きさが決められません。
その答えを知るためには上記の情報に加えて 三角関数を使いViewCの横幅を知る必要があります。

三角関数は、三角形の角度の情報から辺の長さを求めるための公式のようなものです。
ただ三角関数は高校の範囲で、中学ではそのうちの$45$や$60$といった角度の三角形の辺の長さを暗記するといったことをやっているのです。
今回のViewCの右下にできたような、直角と$45^\circ$を持つ三角形は、辺の長さの比が $短い辺:長い辺 = 1:\sqrt{2}$ になることが知られています。

fig5.png

$\sqrt{2} = 1.41421356...$ ですが、細かい数字は要らないのでこの後は $\sqrt{2} \simeq 1.414$ として進めていきます。

fig4.png

すると、ViewCの対角線の半分の大きさが
$$R - \sqrt{2}r$$
になることがわかります。
この事実から、ViewCの高さ(幅)の半分の長さを求めることができます。
すなわち今度は長い辺から短い辺を求めるため$\sqrt{2}$で割ってあげて

$$\frac{R - \sqrt{2}r}{\sqrt{2}} = \frac{R - 1.414*r}{1.414}$$

となり、以上から

$$View C.width(の半分) : View A.width(の半分) = \frac{R - 1.414*r}{1.414} : R = \frac{1 - 1.414*\frac{r}{R}}{1.414} : 1$$

(ただし $R > r$)

に設定すれば、View Bを綺麗に円周上に載せられるという式が得られました :smile:

fig6.png

実際の制約に当てはめる

今回は ViewB.width (r) : ViewA.width (R) = 1 : 10 であったので、

$$ViewC.width : ViewA.width = \frac{10 - 1.414*1}{1.414} : 10 = 0.607 : 1$$

に設定すれば良さそうです。
では実際にAuto Layoutで画面を組んでみます。

環境

  • XCode 9.0.1
  • Swift 4

結果

円形に表示するロジックはSwiftに書きます。

import UIKit

class ViewController: UIViewController {

    // MARK: - Properties
    // MARK: IBOutlet
    @IBOutlet weak var mainImageView: UIView! // ViewAに対応
    @IBOutlet weak var editButtonBackgroundView: UIView! // ViewBに対応

    override func viewDidLoad() {
        super.viewDidLoad()
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // FIXME: なぜか手元だとviewDidLayoutSubviewsでいけなかった
        self.setupSubviews()
    }

    fileprivate func setupSubviews() {
        self.mainImageView.layer.cornerRadius = self.mainImageView.frame.width / 2.0
        self.editButtonBackgroundView.layer.cornerRadius = self.editButtonBackgroundView.frame.width / 2.0
        self.view.setNeedsLayout()
    }
}

また各ViewのClip To Boundsにチェックを入れるのも忘れないように。
まずはわかりやすいように上記のViewAからViewCまでが見えるように配置したときの図。

sample1.png

さらに各Viewを透過したりしてうまく求めていた配置にしたらこうなります。

sample2.png

いい感じに円周上に載っていますね :exclamation:
上記はSEでの表示ですが、7 Plusや8 Plusなどでもちゃんと表示されるはずです (ほかの端末でも試したいけど時間ないのでパスします、すみません :sweat_drops: )

まとめ

ということで、円周上に載せるときの公式は、繰り返しになりますが

$$(ダミーView).width : (メインView).width = \frac{1 - 1.414*\frac{r}{R}}{1.414} : 1$$

$$(ただし r = (円周上に載せるViewの幅), R = (メインビューの幅))$$

です :exclamation:

制約は付け方次第で柔軟にレイアウトを綺麗に組むことができます。
ぜひ機会があればチャレンジしてみてください :hand_splayed:

p.s.

Advent Calendarを参加前日に決めたのでかなり急ぎ目に作って雑になってしまいました :sweat:
もうすこし丁寧に書き直したい願望あります。