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の良い使い方があればコメントお待ちしています!