blob: 0a0a65d9c09844082e45861f5332ceeb335916fb [file] [log] [blame]
package com.airbnb.lottie.samples
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.arch.lifecycle.Lifecycle
import android.arch.lifecycle.Observer
import android.arch.lifecycle.ViewModelProviders
import android.graphics.Color
import android.os.Bundle
import android.support.design.widget.BottomSheetBehavior
import android.support.design.widget.Snackbar
import android.support.transition.AutoTransition
import android.support.transition.TransitionManager
import android.support.v4.app.Fragment
import android.support.v4.content.ContextCompat
import android.support.v7.app.AppCompatActivity
import android.util.Log
import android.view.*
import android.widget.EditText
import androidx.view.children
import androidx.view.isVisible
import com.airbnb.lottie.L
import com.airbnb.lottie.LottieAnimationView
import com.airbnb.lottie.LottieComposition
import com.airbnb.lottie.model.KeyPath
import com.airbnb.lottie.samples.model.CompositionArgs
import com.airbnb.lottie.samples.views.BackgroundColorView
import com.airbnb.lottie.samples.views.BottomSheetItemView
import com.airbnb.lottie.samples.views.BottomSheetItemViewModel_
import com.airbnb.lottie.samples.views.ControlBarItemToggleView
import com.airbnb.lottie.utils.MiscUtils
import com.github.mikephil.charting.components.LimitLine
import com.github.mikephil.charting.components.YAxis
import com.github.mikephil.charting.data.Entry
import com.github.mikephil.charting.data.LineData
import com.github.mikephil.charting.data.LineDataSet
import kotlinx.android.synthetic.main.bottom_sheet_key_paths.*
import kotlinx.android.synthetic.main.bottom_sheet_render_times.*
import kotlinx.android.synthetic.main.bottom_sheet_warnings.*
import kotlinx.android.synthetic.main.control_bar.*
import kotlinx.android.synthetic.main.control_bar_background_color.*
import kotlinx.android.synthetic.main.control_bar_player_controls.*
import kotlinx.android.synthetic.main.control_bar_scale.*
import kotlinx.android.synthetic.main.control_bar_speed.*
import kotlinx.android.synthetic.main.control_bar_trim.*
import kotlinx.android.synthetic.main.fragment_player.*
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.properties.ObservableProperty
import kotlin.reflect.KProperty
private class UiState(private val callback: () -> Unit) {
private inner class BooleanProperty(initialValue: Boolean) : ObservableProperty<Boolean>(initialValue) {
override fun afterChange(property: KProperty<*>, oldValue: Boolean, newValue: Boolean) {
callback()
}
}
var controls by BooleanProperty(true)
var controlBar by BooleanProperty(true)
var renderGraph by BooleanProperty(false)
var border by BooleanProperty(false)
var backgroundColor by BooleanProperty(false)
var scale by BooleanProperty(false)
var speed by BooleanProperty(false)
var trim by BooleanProperty(false)
}
class PlayerFragment : Fragment() {
private val transition = AutoTransition().apply { duration = 175 }
private val uiState = UiState { updateUiFromState() }
private val renderTimesBehavior by lazy {
BottomSheetBehavior.from(renderTimesBottomSheet).apply {
peekHeight = resources.getDimensionPixelSize(R.dimen.bottom_bar_peek_height)
}
}
private val warningsBehavior by lazy {
BottomSheetBehavior.from(warningsBottomSheet).apply {
peekHeight = resources.getDimensionPixelSize(R.dimen.bottom_bar_peek_height)
}
}
private val keyPathsBehavior by lazy {
BottomSheetBehavior.from(keyPathsBottomSheet).apply {
peekHeight = resources.getDimensionPixelSize(R.dimen.bottom_bar_peek_height)
}
}
private val lineDataSet by lazy {
val entries = ArrayList<Entry>(101)
repeat(101) { i -> entries.add(Entry(i.toFloat(), 0f)) }
LineDataSet(entries, "Render Times").apply {
mode = LineDataSet.Mode.CUBIC_BEZIER
cubicIntensity = 0.3f
setDrawCircles(false)
lineWidth = 1.8f
color = Color.BLACK
}
}
private var composition: LottieComposition? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
inflater.inflate(R.layout.fragment_player, container, false)
private val animatorListener = AnimatorListenerAdapter(
onStart = { playButton.isActivated = true },
onEnd = {
playButton.isActivated = false
animationView.performanceTracker?.logRenderTimes()
updateRenderTimesPerLayer()
updateWarnings()
},
onCancel = {
playButton.isActivated = false
updateWarnings()
},
onRepeat = {
animationView.performanceTracker?.logRenderTimes()
updateRenderTimesPerLayer()
updateWarnings()
}
)
private val viewModel by lazy { ViewModelProviders.of(this).get(PlayerViewModel::class.java) }
@SuppressLint("SetTextI18n")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
(requireActivity() as AppCompatActivity).setSupportActionBar(toolbar)
(requireActivity() as AppCompatActivity).supportActionBar?.setDisplayShowTitleEnabled(false)
setHasOptionsMenu(true)
L.setTraceEnabled(true)
lottieVersionView.text = getString(R.string.lottie_version, com.airbnb.lottie.BuildConfig.VERSION_NAME)
val args = arguments?.getParcelable<CompositionArgs>(EXTRA_ANIMATION_ARGS) ?: throw IllegalArgumentException("No composition args specified")
args.animationData?.bgColorInt()?.let {
backgroundButton1.setBackgroundColor(it)
animationContainer.setBackgroundColor(it)
invertColor(it)
}
viewModel.composition.observe(this, Observer {
loadingView.isVisible = false
onCompositionLoaded(it)
})
viewModel.error.observe(this, Observer {
Snackbar.make(coordinatorLayout, R.string.composition_load_error, Snackbar.LENGTH_LONG).show()
Log.w(L.TAG, "Error loading composition.", viewModel.error.value);
})
viewModel.fetchAnimation(args)
borderToggle.setOnClickListener { uiState.border++ }
backgroundColorToggle.setOnClickListener { uiState.backgroundColor++ }
scaleToggle.setOnClickListener { uiState.scale++ }
speedToggle.setOnClickListener { uiState.speed++ }
trimToggle.setOnClickListener { uiState.trim++ }
renderGraphToggle.setOnClickListener { uiState.renderGraph++ }
closeBackgroundColorButton.setOnClickListener { uiState.backgroundColor = false }
closeScaleButton.setOnClickListener { uiState.scale = false }
closeSpeedButton.setOnClickListener { uiState.speed = false }
closeTrimButton.setOnClickListener { uiState.trim = false }
hardwareAccelerationToggle.setOnClickListener {
animationView.useHardwareAcceleration(!animationView.useHardwareAcceleration)
updateUiFromState()
}
mergePathsToggle.setOnClickListener {
animationView.enableMergePathsForKitKatAndAbove(!animationView.isMergePathsEnabledForKitKatAndAbove)
updateUiFromState()
}
seekBar.setOnSeekBarChangeListener(OnSeekBarChangeListenerAdapter(
onProgressChanged = { _, progress, _ ->
if (seekBar.isPressed && progress in 1..4) {
seekBar.progress = 0
return@OnSeekBarChangeListenerAdapter
}
if (animationView.isAnimating) return@OnSeekBarChangeListenerAdapter
animationView.progress = progress / seekBar.max.toFloat()
}
))
animationView.repeatCount = ValueAnimator.INFINITE
animationView.addAnimatorUpdateListener {
currentFrameView.text = updateFramesAndDurationLabel(animationView)
if (seekBar.isPressed) return@addAnimatorUpdateListener
seekBar.progress = ((it.animatedValue as Float) * seekBar.max).roundToInt()
}
animationView.addAnimatorListener(animatorListener)
playButton.setOnClickListener {
if (animationView.isAnimating) animationView.pauseAnimation() else animationView.resumeAnimation()
updateUiFromState()
}
loopButton.setOnClickListener {
val repeatCount = if (animationView.repeatCount == ValueAnimator.INFINITE) 0 else ValueAnimator.INFINITE
animationView.repeatCount = repeatCount
updateUiFromState()
}
scaleSeekBar.setOnSeekBarChangeListener(OnSeekBarChangeListenerAdapter(
onProgressChanged = { _, progress, _ ->
val minScale = minScale()
val maxScale = maxScale()
val scale = minScale + progress / 100f * (maxScale - minScale)
animationView.scale = scale
scaleText.text = "%.0f%%".format(scale * 100)
}
))
minFrame.setOnClickListener {
val minFrameView = EditText(context)
minFrameView.setText(animationView.minFrame.toInt().toString())
AlertDialog.Builder(context)
.setTitle(R.string.min_frame_dialog)
.setView(minFrameView)
.setPositiveButton("Load") { _, _ ->
var frame = minFrameView.text.toString().toFloatOrNull() ?: 0f
frame = MiscUtils.clamp(frame, composition?.startFrame ?: frame, animationView.maxFrame)
animationView.setMinFrame(frame.toInt())
updateUiFromState()
}
.setNegativeButton("Cancel") { dialog, _ -> dialog.dismiss() }
.show()
}
maxFrame.setOnClickListener {
val maxFrameView = EditText(context)
maxFrameView.setText(animationView.maxFrame.toInt().toString())
AlertDialog.Builder(context)
.setTitle(R.string.max_frame_dialog)
.setView(maxFrameView)
.setPositiveButton("Load") { _, _ ->
var frame = maxFrameView.text.toString().toFloatOrNull() ?: 0f
frame = MiscUtils.clamp(frame, animationView.minFrame, composition?.endFrame ?: frame)
animationView.setMaxFrame(frame.toInt())
updateUiFromState()
}
.setNegativeButton("Cancel") { dialog, _ -> dialog.dismiss() }
.show()
}
arrayOf<BackgroundColorView>(
backgroundButton1,
backgroundButton2,
backgroundButton3,
backgroundButton4,
backgroundButton5,
backgroundButton6
).forEach { bb ->
bb.setOnClickListener {
animationContainer.setBackgroundColor(bb.getColor())
invertColor(bb.getColor())
}
}
renderTimesGraph.apply {
setTouchEnabled(false)
axisRight.isEnabled = false
xAxis.isEnabled = false
legend.isEnabled = false
description = null
data = LineData(lineDataSet)
axisLeft.setDrawGridLines(false)
axisLeft.labelCount = 4
val ll1 = LimitLine(16f, "60fps")
ll1.lineColor = Color.RED
ll1.lineWidth = 1.2f
ll1.textColor = Color.BLACK
ll1.textSize = 8f
axisLeft.addLimitLine(ll1)
val ll2 = LimitLine(32f, "30fps")
ll2.lineColor = Color.RED
ll2.lineWidth = 1.2f
ll2.textColor = Color.BLACK
ll2.textSize = 8f
axisLeft.addLimitLine(ll2)
}
speedButtonsContainer.children.forEach {
if (it is ControlBarItemToggleView) {
it.setOnClickListener {
animationView.speed = (it as ControlBarItemToggleView)
.getText()
.replace("x", "")
.toFloat()
updateUiFromState()
}
}
}
renderTimesPerLayerButton.setOnClickListener {
updateRenderTimesPerLayer()
renderTimesBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
closeRenderTimesBottomSheetButton.setOnClickListener {
renderTimesBehavior.state = BottomSheetBehavior.STATE_HIDDEN
}
renderTimesBehavior.state = BottomSheetBehavior.STATE_HIDDEN
warningsButton.setOnClickListener {
updateWarnings()
if (composition?.warnings?.isEmpty() != true) {
warningsBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
}
closeWarningsBottomSheetButton.setOnClickListener {
warningsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
}
warningsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
keyPathsToggle.setOnClickListener {
keyPathsBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
closeKeyPathsBottomSheetButton.setOnClickListener {
keyPathsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
}
keyPathsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
keyPathsRecyclerView.buildModelsWith { controller ->
composition?.let {
animationView.resolveKeyPath(KeyPath("**")).forEachIndexed { index, keyPath ->
BottomSheetItemViewModel_()
.id(index)
.text(keyPath.keysToString())
.addTo(controller)
}
}
}
updateUiFromState()
}
private fun invertColor(color: Int) {
val isDarkBg = color.isDark()
animationView.isActivated = isDarkBg
toolbar.isActivated = isDarkBg
}
private fun Int.isDark(): Boolean {
val y = (299 * Color.red(this) + 587 * Color.green(this) + 114 * Color.blue(this)) / 1000
return y < 128
}
override fun onDestroyView() {
animationView.removeAnimatorListener(animatorListener)
super.onDestroyView()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.fragment_player, menu)
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.isCheckable) item.isChecked = !item.isChecked
when (item.itemId) {
android.R.id.home -> requireActivity().finish()
R.id.info -> Unit
R.id.visibility -> {
uiState.controls = !item.isChecked
uiState.controlBar = !item.isChecked
uiState.renderGraph = false
uiState.border = false
uiState.backgroundColor = false
uiState.scale = false
uiState.speed = false
uiState.trim = false
updateUiFromState()
val menuIcon = if (item.isChecked) R.drawable.ic_eye_teal else R.drawable.ic_eye_selector
item.icon = ContextCompat.getDrawable(requireContext(), menuIcon)
}
}
return true
}
private fun onCompositionLoaded(composition: LottieComposition?) {
composition ?: return
this.composition = composition
animationView.setComposition(composition)
animationView.setMinAndMaxFrame(composition.startFrame.toInt(), composition.endFrame.toInt())
animationView.setPerformanceTrackingEnabled(true)
var renderTimeGraphRange = 4f
animationView.performanceTracker?.addFrameListener { ms ->
if (lifecycle.currentState != Lifecycle.State.RESUMED) return@addFrameListener
lineDataSet.getEntryForIndex((animationView.progress * 100).toInt()).y = ms
renderTimeGraphRange = Math.max(renderTimeGraphRange, ms * 1.2f)
renderTimesGraph.setVisibleYRange(0f, renderTimeGraphRange, YAxis.AxisDependency.LEFT)
renderTimesGraph.invalidate()
}
// Force warning to update
warningsContainer.removeAllViews()
updateWarnings()
// Scale up to fill the screen
scaleSeekBar.progress = 100
keyPathsRecyclerView.requestModelBuild()
}
private fun updateUiFromState() {
beginDelayedTransition()
controlsContainer.isVisible = uiState.controls
controlBar.isVisible = uiState.controlBar
renderGraphToggle.isActivated = uiState.renderGraph
renderTimesGraphContainer.isVisible = uiState.renderGraph
renderTimesPerLayerButton.isVisible = uiState.renderGraph
lottieVersionView.isVisible = !uiState.renderGraph
borderToggle.isActivated = uiState.border
borderToggle.setImageResource(
if (uiState.border) R.drawable.ic_border_on
else R.drawable.ic_border_off
)
animationView.setBackgroundResource(if (borderToggle.isActivated) R.drawable.outline else 0)
backgroundColorToggle.isActivated = uiState.backgroundColor
backgroundColorContainer.isVisible = uiState.backgroundColor
scaleToggle.isActivated = uiState.scale
scaleContainer.isVisible = uiState.scale
trimToggle.isActivated = uiState.trim
trimContainer.isVisible = uiState.trim
// I think this is a lint bug. It complains about int being <ErrorType>
//noinspection StringFormatMatches
minFrame.setText(resources.getString(R.string.min_frame, animationView.minFrame.toInt()))
//noinspection StringFormatMatches
maxFrame.setText(resources.getString(R.string.max_frame, animationView.maxFrame.toInt()))
hardwareAccelerationToggle.isActivated = animationView.useHardwareAcceleration
mergePathsToggle.isActivated = animationView.isMergePathsEnabledForKitKatAndAbove
speedToggle.isActivated = uiState.speed
speedContainer.isVisible = uiState.speed
speedButtonsContainer.children.forEach {
if (it is ControlBarItemToggleView) {
it.isActivated = it.getText().replace("x", "").toFloat() == animationView.speed
}
}
loopButton.isActivated = animationView.repeatCount == ValueAnimator.INFINITE
playButton.isActivated = animationView.isAnimating
}
private fun updateRenderTimesPerLayer() {
renderTimesContainer.removeAllViews()
animationView.performanceTracker?.sortedRenderTimes?.forEach {
val view = BottomSheetItemView(requireContext()).apply {
set(
it.first!!.replace("__container", "Total"),
"%.2f ms".format(it.second!!)
)
}
renderTimesContainer.addView(view)
}
}
private fun updateWarnings() {
val warnings = composition?.warnings ?: emptySet<String>()
if (!warnings.isEmpty() && warnings.size == warningsContainer.childCount) return
warningsContainer.removeAllViews()
warnings.forEach {
val view = BottomSheetItemView(requireContext()).apply {
set(it)
}
warningsContainer.addView(view)
}
val size = warnings.size
warningsButton.setText(resources.getQuantityString(R.plurals.warnings, size, size))
warningsButton.setImageResource(
if (warnings.isEmpty()) R.drawable.ic_sentiment_satisfied
else R.drawable.ic_sentiment_dissatisfied
)
}
private fun minScale() = 0.15f
private fun maxScale(): Float {
val screenWidth = resources.displayMetrics.widthPixels.toFloat()
val screenHeight = resources.displayMetrics.heightPixels.toFloat()
return min(
screenWidth / (composition?.bounds?.width()?.toFloat() ?: screenWidth),
screenHeight / (composition?.bounds?.height()?.toFloat() ?: screenHeight)
)
}
private fun beginDelayedTransition() = TransitionManager.beginDelayedTransition(container, transition)
companion object {
const val EXTRA_ANIMATION_ARGS = "animation_args"
fun forAsset(args: CompositionArgs): Fragment {
return PlayerFragment().apply {
arguments = Bundle().apply {
putParcelable(EXTRA_ANIMATION_ARGS, args)
}
}
}
}
private fun updateFramesAndDurationLabel(animation: LottieAnimationView): String {
val currentFrame = animation.frame.toString()
val totalFrames = ("%.0f").format(animation.maxFrame)
val animationSpeed: Float = Math.abs(animation.speed)
val totalTime = ((animation.duration / animationSpeed) / 1000.0)
val totalTimeFormatted = ("%.1f").format(totalTime)
val progress = (totalTime / 100.0) * (Math.round(animation.progress * 100.0))
val progressFormatted = ("%.1f").format(progress)
return "$currentFrame/$totalFrames\n$progressFormatted/$totalTimeFormatted"
}
}