Memory Leak in Custom Alert dialog animation Android - android

I'm using an alert dialog to show an animation (green tick mark on success api call). But it's causing a memory leak if I press the home button before the animation ends.
To reproduce, I enabled "Finish Activities" in Developer Options.
Attaching the code below for "Tick Animation" and the custom dialog box which shows that animation.
SuccessAnimation.kt
class SuccessAnimation #JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null,
) : RelativeLayout(context, attrs) {
private val circleFadeInTime: Long
private val checkAnimationTime: Long
val postAnimationTime: Long
init {
inflate(context, R.layout.success_content, this)
val a = context.obtainStyledAttributes(attrs, R.styleable.SuccessAnimation, 0, 0)
try {
circleFadeInTime = a.getInteger(R.styleable.SuccessAnimation_circleAppearanceTime, DEFAULT_CIRCLE_TIME).toLong()
checkAnimationTime = a.getInteger(R.styleable.SuccessAnimation_checkMarkAnimationTime, DEFAULT_ANIMATION_TIME).toLong()
postAnimationTime = a.getInteger(R.styleable.SuccessAnimation_postAnimationTime, DEFAULT_POST_ANIMATION_TIME).toLong()
} finally {
a.recycle()
}
isClickable = true // Prevent anything else from happening!
}
val animationDuration = circleFadeInTime + checkAnimationTime + postAnimationTime
private val circle: View = findViewById(R.id.green_circle)
private val checkMark = findViewById<AnimatedCheckMark>(R.id.check_mark).apply { setAnimationTime(checkAnimationTime) }
private var onAnimationFinished: (() -> Unit)? = {}
/**
* Set a callback to be invoked when the animation finishes
* #param listener listener to be called
*/
fun setSuccessFinishedListener(listener: (() -> Unit)?) {
this.onAnimationFinished = listener
}
/**
* start the animation, also handles making sure the view is visible.
*/
fun show() {
if (visibility != VISIBLE) {
visibility = VISIBLE
post { show() }
return
}
circle.visibility = VISIBLE
circle.scaleX = 0f
circle.scaleY = 0f
val animator = ValueAnimator.ofFloat(0f, 1f)
animator.addUpdateListener { animation: ValueAnimator ->
val scale = animation.animatedFraction
circle.scaleY = scale
circle.scaleX = scale
circle.invalidate()
}
animator.duration = circleFadeInTime
animator.interpolator = OvershootInterpolator()
animator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
checkMark!!.visibility = VISIBLE
invalidate()
checkMark.startAnimation { view: AnimatedCheckMark ->
view.postDelayed({
visibility = GONE
checkMark.visibility = INVISIBLE
circle.visibility = INVISIBLE
onAnimationFinished?.invoke()
}, postAnimationTime)
}
}
})
invalidate()
animator.start()
}
companion object{
private const val DEFAULT_CIRCLE_TIME = 300
private const val DEFAULT_ANIMATION_TIME = 500
private const val DEFAULT_POST_ANIMATION_TIME = 500
}
}
SuccessAnimationPopup.kt
class SuccessAnimationPopup(context: Context,
private val callback: () -> Unit) :
AlertDialog(context, android.R.style.Theme_Translucent_NoTitleBar) {
init {
window?.let {
it.setFlags(
WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION,
WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION
)
it.setFlags(
WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS,
WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS
)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.success_animation_popup)
setCancelable(false)
val success = findViewById<SuccessAnimation>(R.id.success_animation)
success?.setSuccessFinishedListener {
dismiss()
callback()
}
success?.show()
}
}
It is being used like the following:
SuccessAnimationPopup(view.context) {}.show() I only have "view" here and not the activity.
Been trying to find the root cause. Have also added onDetachedFromWindow lifecycle callback in SuccessAnimation.kt and tried setting the onAnimationListener = null, still doesn't work.
What am I missing here?
Also, the AlertDialog constructor is not accepting a nullable context, hence I wasn't able to pass a WeakReference since it's nullable.

Related

Kotlin recycler view cannot change background when later calling a function

I'm new to Kotlin and so if you do find rubbish code here and poor practices, do let me know. Otherwise, here is the issue I am having.
I am writing a tiny app that presents users with multiple questions from which they have to select the correct answer. If they select the correct answer, the option is supposed to be highlighted green for 250ms and then they move on to the next question. Otherwise, select the incorrect answer. The logic for moving onto the next question is defined in the main activity, and the background change logic is defined in the adapter class. Below is what the adapter class looks like at the moment (I've only included that which I think is relevant just to add too much faff):
class QuestionOptionAdapter(
private val items: ArrayList<String>,
private val correctAnswer: String,
) : RecyclerView.Adapter<QuestionOptionAdapter.ViewHolder>() {
var onSelectedAnswer: (String) -> Unit = {}
var onSelectedCorrectAnswer: () -> Unit = {}
var onSelectedIncorrectAnswer: () -> Unit = {}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
holder.tvOption.text = item
holder.tvOption.setOnClickListener {
if (item == correctAnswer) {
runBlocking {
it.background =
ContextCompat.getDrawable(it.context, R.drawable.question_opt_correct)
delay(250)
}
onSelectedCorrectAnswer()
} else {
it.background =
ContextCompat.getDrawable(it.context, R.drawable.question_opt_incorrect)
onSelectedIncorrectAnswer()
}
}
}
}
I realised that although the code to changes the background is executed before onSelectedCorrectAnswer(), it won't change the background colour until the entire block has finished executing. Therefore, the user never sees the updated background.
Is there a way to show an update before the block finishes executing?
runBlocking doesn't work because it blocks. It will just wait for the whole time you delay and block the main thread so the device will be frozen and not show any visual changes until it returns.
You need to pass the Activity or Fragment's CoroutineScope into the adapter for the adapter to use. You can then launch a coroutine that won't block the main thread when you delay inside it.
Here I lifted the coroutine to encompass all your click listener logic. That will make it easier to modify the behavior later if you want.
class QuestionOptionAdapter(
private val scope: CoroutineScope,
private val items: ArrayList<String>,
private val correctAnswer: String,
) : RecyclerView.Adapter<QuestionOptionAdapter.ViewHolder>() {
var onSelectedAnswer: (String) -> Unit = {}
var onSelectedCorrectAnswer: () -> Unit = {}
var onSelectedIncorrectAnswer: () -> Unit = {}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
holder.tvOption.text = item
holder.tvOption.setOnClickListener { view ->
scope.launch {
val isCorrect = item == correctAnswer
val colorDrawable =
if (isCorrect) R.drawable.question_opt_correct
else R.drawable.question_opt_incorrect
view.background = ContextCompat.getDrawable(view.context, colorDrawable)
if (isCorrect) {
delay(250)
onSelectedCorrectAnswer()
} else {
onSelectedIncorrectAnswer()
}
}
}
}
}
Actually, you probably want to also prevent the user from clicking other options during that 250ms delay, so you should set a Boolean that can disable further clicking of items during the delay:
class QuestionOptionAdapter(
private val scope: CoroutineScope,
private val items: ArrayList<String>,
private val correctAnswer: String,
) : RecyclerView.Adapter<QuestionOptionAdapter.ViewHolder>() {
var onSelectedAnswer: (String) -> Unit = {}
var onSelectedCorrectAnswer: () -> Unit = {}
var onSelectedIncorrectAnswer: () -> Unit = {}
private var isLockClickListeners = false
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
holder.tvOption.text = item
holder.tvOption.setOnClickListener { view ->
if (isLockClickListeners) {
return#setOnClickListener
}
scope.launch {
val isCorrect = item == correctAnswer
val colorDrawable =
if (isCorrect) R.drawable.question_opt_correct
else R.drawable.question_opt_incorrect
view.background = ContextCompat.getDrawable(view.context, colorDrawable)
if (isCorrect) {
isLockClickListeners = true
delay(250)
onSelectedCorrectAnswer()
isLockClickListeners = false
} else {
onSelectedIncorrectAnswer()
}
}
}
}
}

Progressbar not updating after Redux Event

I know this question has been asked quite often here, but non of the answers helped me.
I am writting a gallery app with a thumbes-regeneration feature. In oder to show the progress i added the progressbar which should count the number of created thumbnails. After each finished thumbnail-generation i dispatch a Redux event and listen to it in my Fragement, in order to change the progressbar.
Generating all thumbnails for all visible photos/videos
private fun onMenuRefreshThumbs(activity: Activity) {
val mediaPath = Redux.store.currentState.mediaPath
val fileRepository = FileRepository(context = activity, mediaPath = mediaPath)
activity.runOnUiThread {
fileRepository.regenerateThumbs(activity)
}
}
Functions inside the above used FileRepository:
fun regenerateThumbs(context: Context) {
val success = File(getAbsoluteThumbsDir(context, mediaPath)).deleteRecursively()
getMediaItems()
}
fun getMediaItems(): MediaItemList {
val success = File(thumbPath).mkdirs()
val isThumbsEmpty = File(thumbPath).listFiles().isEmpty()
val mediaFileList = File(mediaPath).listFiles().
.sortedByDescending { it.lastModified() }
val list = MediaItemList()
mediaFileList.apply {
forEach {
list.add(MediaItem(it.name, 0, 0))
if (isThumbsEmpty) {
getOrCreateThumb(it)
Redux.store.dispatch(FileUpdateAction(it))
}
}
}
return list
}
Subscribing to Redux in the Fragement:
private fun subscribeRedux() {
val handler = Handler(Looper.getMainLooper())
val activity = requireActivity()
subscriber = { state: AppState ->
when (state.action) {
...
is ClearSelection -> {
progressCounter = 0
// fragment_gallery_progress.visibility = View.GONE
}
is FileUpdateAction -> {
Handler().post {
progressCounter++
fragment_gallery_progress.visibility = View.VISIBLE
fragment_gallery_progress.progress = progressCounter
// fragment_gallery_progress.invalidate()
log.d("test: Thumb Index $progressCounter ${state.action.mediaItem.name} was created")
}
Unit
}
}
}.apply {
Redux.store.subscribe(this)
}
}
I tried all difference version of calling a thread in both cases. But no matter if its done with the handler or by activity.runOnUiThread, the progressbar never changes untill all thumbs are finished and the progressbar jumps from 0 to the maximum number. I can see the logs which are written in the right time, but not the progressbar changing.
I could fix my problem with following steps:
Removing the runOnUiThread() call
private fun onMenuRefreshThumbs(activity: Activity) {
val mediaPath = Redux.store.currentState.mediaPath
val fileRepository = FileRepository(context = activity, mediaPath = mediaPath)
fileRepository.regenerateThumbs(activity)
}
Adding a thread for each Thumbs-Generation:
fun getMediaItems(): MediaItemList {
val success = File(thumbPath).mkdirs()
val isThumbsEmpty = File(thumbPath).listFiles().isEmpty()
val mediaFileList = File(mediaPath).listFiles().
.sortedByDescending { it.lastModified() }
val list = MediaItemList()
mediaFileList.apply {
forEach {
list.add(MediaItem(it.name, 0, 0))
if (isThumbsEmpty) {
Thread {
getOrCreateThumb(it)
Redux.store.dispatch(FileUpdateAction(it))
}.start()
}
}
...

Turning listeners into kotlin coroutine channels

I have several functions that I want to use to do pipelines with Channels. The main one is globalLayouts, where I create a Channel from the framework listener:
fun View.globalLayouts(): ReceiveChannel<View> =
Channel<View>().apply {
val view = this#globalLayouts
val listener = ViewTreeObserver.OnGlobalLayoutListener {
offer(view)
}
invokeOnClose {
viewTreeObserver.removeOnGlobalLayoutListener(listener)
}
viewTreeObserver.addOnGlobalLayoutListener(listener)
}
#UseExperimental(InternalCoroutinesApi::class)
fun <E> ReceiveChannel<E>.distinctUntilChanged(context: CoroutineContext = Dispatchers.Unconfined): ReceiveChannel<E> =
GlobalScope.produce(context, onCompletion = consumes()) {
var last: Any? = Any()
consumeEach {
if (it != last) {
send(it)
last = it
}
}
}
fun View.keyboardVisibility(): ReceiveChannel<KeyboardVisibility> {
val rect = Rect()
return globalLayouts()
.map {
getWindowVisibleDisplayFrame(rect)
when (rect.height()) {
height -> KeyboardVisibility.HIDDEN
else -> KeyboardVisibility.SHOWN
}
}
.distinctUntilChanged()
}
I have a CoroutineScope called alive:
val ControllerLifecycle.alive: CoroutineScope
get() {
val scope = MainScope()
addLifecycleListener(object : Controller.LifecycleListener() {
override fun preDestroyView(controller: Controller, view: View) {
removeLifecycleListener(this)
scope.cancel()
}
})
return scope
}
then I do:
alive.launch {
root.keyboardVisibility().consumeEach {
appbar.setExpanded(it == KeyboardVisibility.HIDDEN)
}
}
This code starts working just fine, but I get
kotlinx.coroutines.JobCancellationException: Job was cancelled; job=JobImpl{Cancelled}#811031f
once my alive scope is destroyed. Right after invokeOnClose is called in globalLayouts. What am I doing wrong and how do I debug this?
Figured it out - the code works fine, but
viewTreeObserver.removeOnGlobalLayoutListener(listener)
is bugged for CoordinatorLayout.

Ripple effect in custom view

I am currently creating an Android view in which, when the use tap it, I will display a sort of ripple around the coordinate of the tap.
But I'm not sure on how to do it. My first idea was to invalidate the cache and just make the circle bigger each time but it doesn't seem appropriate nor efficient to do this like that.
If anyone faced the same problem before and would love the share some tips on how to do it it would be much appreciated.
I finally found out a solution. Not a perfect one but it works for now.
Here is the code I did. Basically when I need it I change a boolean to true so my onDrawfunction knows that it have to execute the drawFingerPrintfunction.
The drawFingerPrint function, in the other end, just draw a circle that's bigger and bigger between each iteration until it reaches the diameter needed
private fun drawFingerPrint(canvas: Canvas) {
canvas.drawCircle(pointerX, pointerY, radius, paint)
if(radius<= 100F){
radius+=10F
invalidate()
}
else{
radius = 0F
drawAroundFinger = false
invalidate()
}
}
I hope someone else will find this useful sometimes!
Matthieu
As already mentioned #Matthieu, you need to draw circle on the canvas and invalidate the view. Here I provide a more complete example.
So we have an open class RippleView:
open class RippleView #JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private var rippleX: Float? = null
private var rippleY: Float? = null
private var rippleRadius: Float? = null
var maxRippleRadius: Float = 100f // Retrieve from resources
var rippleColor: Int = 0x88888888.toInt()
private val ripplePaint = Paint().apply {
color = rippleColor
}
private val animationExpand = object : Runnable {
override fun run() {
rippleRadius?.let { radius ->
if (radius < maxRippleRadius) {
rippleRadius = radius + maxRippleRadius * 0.1f
invalidate()
postDelayed(this, 10L)
}
}
}
}
private val animationFade = object : Runnable {
override fun run() {
ripplePaint.color.let { color ->
if (color.alpha > 10) {
ripplePaint.color = color.adjustAlpha(0.9f)
invalidate()
postDelayed(this, 10L)
} else {
rippleX = null
rippleY = null
rippleRadius = null
invalidate()
}
}
}
}
fun startRipple(x: Float, y: Float) {
rippleX = x
rippleY = y
rippleRadius = maxRippleRadius * 0.15f
ripplePaint.color = rippleColor
animationExpand.run()
}
fun stopRipple() {
if (rippleRadius != null) {
animationFade.run()
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val x = rippleX ?: return
val y = rippleY ?: return
val r = rippleRadius ?: return
canvas.drawCircle(x, y, r, ripplePaint)
}
}
fun Int.adjustAlpha(factor: Float): Int =
(this.ushr(24) * factor).roundToInt() shl 24 or (0x00FFFFFF and this)
inline val Int.alpha: Int
get() = (this shr 24) and 0xFF
Now you can extend any custom view with RippleView and use startRipple method when you get ACTION_DOWN, and stopRipplemethod when you get ACTION_UP:
class ExampleView #JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : RippleView(context, attrs), View.OnTouchListener {
init {
setOnTouchListener(this)
}
override fun onTouch(v: View, event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
if (isInsideArea(event.x, event.y)) {
startRipple(event.x, event.y)
}
}
MotionEvent.ACTION_UP -> {
stopRipple()
}
}
return false
}
private fun isInsideArea(x: Float, y: Float): Boolean {
TODO("Not yet implemented")
}
}

Pause progress of ObjectAnimator

I'm implementing a custom view which draws some kind ProgressBar view, taking two views as parameters (origin and destination). Like this:
This is the complete class:
class BarView #JvmOverloads constructor(context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) {
private var valueAnimator: ObjectAnimator? = null
private lateinit var path: Path
private val pathMeasure = PathMeasure()
private var pauseProgress: Int = dip(40)
var progress = 0f
set(value) {
field = value.coerceIn(0f, pathMeasure.length)
invalidate()
}
private var originPoint: PointF? = null
private var destinationPoint: PointF? = null
private val cornerEffect = CornerPathEffect(dip(10).toFloat())
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = dip(10f).toFloat()
color = ContextCompat.getColor(context, android.R.color.darker_gray)
pathEffect = cornerEffect
}
override fun draw(canvas: Canvas) {
super.draw(canvas)
if (progress < pathMeasure.length) {
val intervals = floatArrayOf(progress, pathMeasure.length - progress)
val progressEffect = DashPathEffect(intervals, 0f)
linePaint.pathEffect = ComposePathEffect(progressEffect, cornerEffect)
}
canvas.drawPath(path, linePaint)
}
object PROGRESS : Property<BarView, Float>(Float::class.java, "progress") {
override fun set(view: BarView, progress: Float) {
view.progress = progress
}
override fun get(view: BarView) = view.progress
}
private fun startAnimator() {
valueAnimator = ObjectAnimator.ofFloat(this, PROGRESS, 0f, pathMeasure.length).apply {
duration = 500L
interpolator = LinearInterpolator()
}
setPauseListener()
valueAnimator!!.start()
}
fun resume() {
valueAnimator!!.resume()
}
fun reset() {
startAnimator()
}
fun setPoints(originView: View, destinationView: View) {
originPoint = PointF(originView.x + originView.width / 2, 0f)
destinationPoint = PointF(destinationView.x + destinationView.width / 2, 0f)
setPath()
startAnimator()
}
private fun setPath() {
path = Path()
path.moveTo(originPoint!!.x, originPoint!!.y)
path.lineTo(destinationPoint!!.x, destinationPoint!!.y)
pathMeasure.setPath(path, false)
}
private fun setPauseListener() {
valueAnimator!!.addUpdateListener(object : ValueAnimator.AnimatorUpdateListener {
override fun onAnimationUpdate(valueAnimator: ValueAnimator?) {
val progress = valueAnimator!!.getAnimatedValue("progress") as Float
if (progress > pauseProgress) {
valueAnimator.pause()
this#BarView.valueAnimator!!.removeUpdateListener(this)
}
}
})
}
}
What im trying to do is to pause the animation at a specific progress, 40dp in this case:
private fun setPauseListener() {
valueAnimator!!.addUpdateListener(object : ValueAnimator.AnimatorUpdateListener {
override fun onAnimationUpdate(valueAnimator: ValueAnimator?) {
val progress = valueAnimator!!.getAnimatedValue("progress") as Float
if (progress > pauseProgress) {
valueAnimator.pause()
this#BarView.valueAnimator!!.removeUpdateListener(this)
}
}
})
}
But the animations have different speeds since the views have different path lengths, and all of them have to finish in 500ms. They are not pausing at the same distance from the origin:
I tried switching from a LinearInterpolator to a AccelerateInterpolator, to make the start of the animation slower, but i'm still not satisfied with the results.
The next step for me, would be to try to implement a custom TimeInterpolator to make the animation start speed the same no matter how long the path is on each view, but I cannot wrap my head arrow the maths to create the formula needed.
valueAnimator = ObjectAnimator.ofFloat(this, PROGRESS, 0f, pathMeasure.length).apply {
duration = 500L
interpolator = TimeInterpolator { input ->
// formula here
}
}
Any help with that would be well received. Any suggestions about a different approach. What do you think?
1) As I mentioned I would use determinate ProgressBar and regulate bar width by maximum amount of progress, assigned to certain view
2) I would use ValueAnimator.ofFloat with custom interpolator and set progress inside it
3) I would extend my custom interpolator from AccelerateInterpolator to make it look smthng like this:
class CustomInterpolator(val maxPercentage: Float) : AccelerateInterpolator(){
override fun getInterpolation(input: Float): Float {
val calculatedVal = super.getInterpolation(input)
return min(calculatedVal, maxPercentage)
}
}
where maxPercentage is a fraction of view width (from 0 to 1) you bar should occupy
Hope it helps

Categories

Resources