Help us understand the problem. What is going on with this article?

ConstraintLayoutのCircular positioningでアナログ時計を作った

More than 1 year has passed since last update.

はじめに

この記事はConstraintLayoutのCircular positioningを使って円状にViewを配置してみるの続編です。

2018年4月にConstraintLayout 1.1がリリースされました。
この記事では正式にリリースされた機能の1つであるCircular positioningとそれを使ったサンプルについて書いていきます。

Circular positioningって?

"はじめに"で紹介した記事で触れていますが、View Aの中心を原点としてView Bを極座標で配置できるものです。
https://developer.android.com/reference/android/support/constraint/ConstraintLayout
68747470733a2f2f71696974612d696d6167652d73746f72652e73332e616d617a6f6e6177732e636f6d2f302f3232303830312f63353137323264312d353735612d306336642d316336342d3831363639613930343032392e706e67.png68747470733a2f2f71696974612d696d6167652d73746f72652e73332e616d617a6f6e6177732e636f6d2f302f3232303830312f34663539306432392d323565622d646565352d623034322d3662376264323066353231642e706e67.png

iOSのAutoLayoutとConstraintLayoutは似ていますが、この極座標配置はiOSには無い(はず)です。

今回はCircular positioningの活用について探っていきましょう。
サンプルを作ったのでそちらで紹介します。

実装サンプル

Kotlinで実装しています(初めてKotlinで書いたので拙い部分はご容赦を...)。
ソースコードは以下に置いたので手元で実行して見てください。
https://github.com/taletail/CircularPositioning

環境

ConstraintLayout 1.1
Android Studio 3.0.1

サンプル: アナログ時計

真っ先に浮かんだCicular positioningの使い道は「アナログ時計を作る」ことでした。
時計の文字盤の配置は円状ですので、1から12までの文字のTextViewを等間隔に並べれば作れそうです。

AnalogClock.png

良いですね!シンプルなアナログ時計を表示できました。ちゃんと針も動いてくれます。これはRxJavaのintervalで定期実行しています。
(正直文字盤より短針長針の実装の方が時間かかった)

ClockFragment.kt
class ClockFragment : Fragment() {

    // 時間の分割数
    private val PARTITIONS = 12

    private lateinit var binding: FragmentClockBinding
    private lateinit var hourHand: ImageView
    private lateinit var minuteHand: ImageView

    private val observer: Observable<DateTime> =
            Observable.interval(20, TimeUnit.SECONDS)
                    .map { DateTime(System.currentTimeMillis(), DateTimeZone.forOffsetHours(9)) }
                    .subscribeOn(Schedulers.io())
                    .observeOn(AndroidSchedulers.mainThread())

    private val compositeDisposable = CompositeDisposable()


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_clock, container, false)
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)

        binding = FragmentClockBinding.bind(view!!)
        hourHand = binding.hourHand
        minuteHand = binding.minuteHand

        val root = binding.root
        val centralView = binding.centralView

        val radius = resources.getDimensionPixelSize(R.dimen.circle_radius)

        // 文字盤をTextViewで作っていく
        for (i in 1..PARTITIONS) {
            val textView = TextView(context)
            textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.dimen.text_size))

            val layoutParams = ConstraintLayout.LayoutParams(
                    ConstraintLayout.LayoutParams.WRAP_CONTENT,
                    ConstraintLayout.LayoutParams.WRAP_CONTENT)

            layoutParams.circleConstraint = centralView.id
            layoutParams.circleAngle = CircleUtil.computeAngleByIndex(PARTITIONS, i)
            layoutParams.circleRadius = radius
            textView.layoutParams = layoutParams

            textView.text = i.toString()

            root.addView(textView)
        }

        // 時針、分針の角度の初期値設定
        val now = DateTime(System.currentTimeMillis(), DateTimeZone.forOffsetHours(9))
        setClockSetting(now.hourOfDay, now.minuteOfHour)
    }

    override fun onStart() {
        super.onStart()

        val disposable = observer.subscribe(
                {
                    Log.i(TAG, "hour: " + it.hourOfDay + " minute: " + it.minuteOfHour)
                    setClockSetting(it.hourOfDay, it.minuteOfHour)
                },
                {
                    it.printStackTrace()
                },
                {
                    Log.i(TAG, "complete")
                }
        )

        compositeDisposable.add(disposable)
    }

    override fun onStop() {
        super.onStop()

        compositeDisposable.clear()
    }

    // 時針、分針を時間に合わせて回転させる
    private fun setClockSetting(hour: Int, minute: Int) {
        // hourは12時間に丸める
        val hour12 = hour % 12

        hourHand = binding.hourHand
        minuteHand = binding.minuteHand

        // 時針、分針を回転させる
        hourHand.rotation = CircleUtil.computeAngleByHour(hour12, minute)
        minuteHand.rotation = CircleUtil.computeAngleByMinite(minute)
    }
}
CircleUtil.kt
object CircleUtil {
    private const val CIRCLE_RADIUS = 360

    /**
     * index番目の要素の角度を計算して返す
     *
     * @param partition 分割数
     * @param index
     */
    fun computeAngleByIndex(partitions: Int, index: Int): Float {
        val angleUnit = (CIRCLE_RADIUS / partitions).toFloat()
        return index * angleUnit
    }

    fun computeAngleByMinite(minute: Int): Float {
        val angleUnit = (CIRCLE_RADIUS / 60).toFloat()
        return minute * angleUnit
    }

    fun computeAngleByHour(hour: Int, minute: Int): Float {
        val angleUnit = CIRCLE_RADIUS.toFloat() / (12 * 60)
        return (hour * 60 + minute) * angleUnit
    }
}

ちなみにアナログ時計を作るならAnalogClockというコンポーネントを使った方が楽...と言いたかったのですが、API level 23からdeprecatedになっていました。
代替できるクラスとかあるんでしょうか?

おまけ1: こいつ、回るぞ

アクションバーのメニューから「Rolling」をタップすると開けます。

Rolling.gif

なんと時計の文字盤が回転します!時計として使えねぇ!
一応短針長針は正しい時間を指したままにしてありますので時間はなんとなく分かります。

実装としては数字のTextViewの角度をValueAnimatorを使って定期的に変化させています。

RollingClockFragment.kt
class RollingClockFragment : Fragment() {

    private val PARTITIONS = 12

    private lateinit var binding: FragmentRollingClockBinding
    private lateinit var hourHand: ImageView
    private lateinit var minuteHand: ImageView

    private val numberAnimators: MutableList<ValueAnimator> = ArrayList()

    private val observer: Observable<DateTime> =
            Observable.interval(20, TimeUnit.SECONDS)
                    .map { DateTime(System.currentTimeMillis(), DateTimeZone.forOffsetHours(9)) }
                    .subscribeOn(Schedulers.io())
                    .observeOn(AndroidSchedulers.mainThread())

    private val compositeDisposable = CompositeDisposable()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_rolling_clock, container, false)
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)

        binding = FragmentRollingClockBinding.bind(view!!)
        hourHand = binding.hourHand
        minuteHand = binding.minuteHand

        val root = binding.root
        val centralView = binding.centralView

        val radius = resources.getDimensionPixelSize(R.dimen.circle_radius)

        for (i in 1..PARTITIONS) {
            val textView = TextView(context)
            textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.dimen.text_size))

            val layoutParams = ConstraintLayout.LayoutParams(
                    ConstraintLayout.LayoutParams.WRAP_CONTENT,
                    ConstraintLayout.LayoutParams.WRAP_CONTENT)

            layoutParams.circleConstraint = centralView.id
            layoutParams.circleAngle = CircleUtil.computeAngleByIndex(PARTITIONS, i)
            layoutParams.circleRadius = radius
            textView.layoutParams = layoutParams

            textView.text = i.toString()

            root.addView(textView)

            numberAnimators.add(animateNumber(textView, layoutParams.circleAngle))
        }

        // 時針、分針の角度の初期値設定
        val now = DateTime(System.currentTimeMillis(), DateTimeZone.forOffsetHours(9))
        setClockSetting(now.hourOfDay, now.minuteOfHour)
    }

    override fun onStart() {
        super.onStart()

        val disposable = observer.subscribe(
                {
                    Log.i(TAG, "hour: " + it.hourOfDay + " minute: " + it.minuteOfHour)
                    setClockSetting(it.hourOfDay, it.minuteOfHour)
                },
                {
                    it.printStackTrace()
                },
                {
                    Log.i(TAG, "complete")
                }
        )

        compositeDisposable.add(disposable)

        // 文字盤を回転させるアニメーション
        numberAnimators.forEach {
            it.start()
        }
    }

    override fun onStop() {
        super.onStop()

        numberAnimators.forEach {
            it.cancel()
        }
        compositeDisposable.clear()
    }

    // 時針、分針を時間に合わせて回転させる
    private fun setClockSetting(hour: Int, minute: Int) {
        // hourは12時間に丸める
        val hour12 = hour % 12

        hourHand = binding.hourHand
        minuteHand = binding.minuteHand

        // 時針、分針を回転させる
        hourHand.rotation = CircleUtil.computeAngleByHour(hour12, minute)
        minuteHand.rotation = CircleUtil.computeAngleByMinite(minute)
    }

    private fun animateNumber(number: TextView, initAngle: Float): ValueAnimator {
        val animator = ValueAnimator.ofInt(0, 359)

        animator.addUpdateListener { valueAnimator ->
            val value = valueAnimator.animatedValue as Int
            val layoutParams = number.layoutParams as ConstraintLayout.LayoutParams
            layoutParams.circleAngle = initAngle + value
            number.layoutParams = layoutParams
        }

        animator.duration = TimeUnit.SECONDS.toMillis(5)
        animator.interpolator = LinearInterpolator()
        animator.repeatMode = ValueAnimator.RESTART
        animator.repeatCount = ValueAnimator.INFINITE

        return animator
    }
}

(レイアウトファイルは同じなので省略)

アニメーションの実装はこちらの記事を大変参考にさせていただきました。

おまけ2: 2重に回るぞ

アクションバーのメニューから「Double Rolling」をタップすると開けます。

内側のViewが回転し、制約をつけておいた外側のViewも一緒に動きます。
真ん中のボタンを押すと外側のViewも回転します。
こういうの見ると2重振り子とかできそうですよね。多分大変なのでやらないですけど。

DoubleRolling.gif

DoubleRollingFragment.kt
class DoubleRollingFragment : Fragment() {

    private lateinit var binding: FragmentDoubleRollingBinding

    private lateinit var rollButton: Button

    private lateinit var firstAnimator: ValueAnimator
    private lateinit var secondAnimator: ValueAnimator

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.fragment_double_rolling, container, false)
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)

        binding = FragmentDoubleRollingBinding.bind(view!!)
        binding.fragment = this

        rollButton = binding.rollButton
        val firstView = binding.first
        val secondView = binding.second

        val firstLayoutParams = firstView.layoutParams as ConstraintLayout.LayoutParams
        val secondLayoutParams = secondView.layoutParams as ConstraintLayout.LayoutParams

        firstAnimator = animateView(firstView, firstLayoutParams.circleAngle, TimeUnit.SECONDS.toMillis(5))
        secondAnimator = animateView(secondView, secondLayoutParams.circleAngle, TimeUnit.SECONDS.toMillis(2))
    }

    override fun onStart() {
        super.onStart()

        firstAnimator.start()
    }

    override fun onStop() {
        super.onStop()

        firstAnimator.cancel()
        secondAnimator.cancel()
    }

    private fun animateView(view: View, initAngle: Float, duration: Long): ValueAnimator {
        val animator = ValueAnimator.ofInt(0, 359)

        animator.addUpdateListener { valueAnimator ->
            val value = valueAnimator.animatedValue as Int
            val layoutParams = view.layoutParams as ConstraintLayout.LayoutParams
            layoutParams.circleAngle = initAngle + value
            view.layoutParams = layoutParams
        }

        animator.duration = duration
        animator.interpolator = LinearInterpolator()
        animator.repeatMode = ValueAnimator.RESTART
        animator.repeatCount = ValueAnimator.INFINITE

        return animator
    }

    fun onClick(view: View) {
        secondAnimator.start()
    }
}

おわりに

サンプルではCircular positioningの使い方が"はじめに"で紹介した実装と同じになってしまったので新規性が薄いのですが...おまけ1ではアニメーションとの組み合わせ、おまけ2では制約をつけた先のView(赤)が動くと、何もしなくても元のView(青)も合わせて動くという知見がありました(Constraintの性質を考えたら当然ではあるのですが)。

最初にCircular positioningの活用方法について探ると言っておいてなんですが、やはり円形のレイアウトを組むのに使う以外に思いつきませんでした。
ですがちゃんと計算式を組めば楕円形の配置にすることもできるでしょうし、あるいはゲーム的な動きを持たせるのにも使えるかも...。
アニメーションと組み合わせるというのも1つのヒントかもしれません。

何かCircular positioningの良い使い方があればコメントお待ちしています!

taletail
現在Androidエンジニアをやっています。 学生時代はRails 4でWeb系の勉強をしていました。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away