はじめに
この記事は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
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を等間隔に並べれば作れそうです。
良いですね!シンプルなアナログ時計を表示できました。ちゃんと針も動いてくれます。これはRxJavaのintervalで定期実行しています。
(正直文字盤より短針長針の実装の方が時間かかった)
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)
}
}
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」をタップすると開けます。
なんと時計の文字盤が回転します!時計として使えねぇ!
一応短針長針は正しい時間を指したままにしてありますので時間はなんとなく分かります。
実装としては数字のTextViewの角度をValueAnimatorを使って定期的に変化させています。
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重振り子とかできそうですよね。多分大変なのでやらないですけど。
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の良い使い方があればコメントお待ちしています!