LouisCAD / Splitties

A collection of hand-crafted extensions for your Kotlin projects.
https://splitties.louiscad.com
Apache License 2.0
2.5k stars 159 forks source link

TextView Marquee speed extension #183

Open Ribesg opened 5 years ago

Ribesg commented 5 years ago

As discussed on Slack, here's a proposal for an extension which uses reflection to change the marquee ellipsis animation speed.

import android.os.Build.VERSION.SDK_INT
import android.text.Editable
import android.text.TextWatcher
import android.widget.TextView
import androidx.core.view.doOnNextLayout
import splitties.dimensions.dp
import java.lang.reflect.Field
import java.util.*

/**
 * The marquee speed in pixels per second.
 *
 * The default value is 30dp.
 */
var TextView.marqueeSpeed: Float
    get() = marquee?.speed ?: marqueeSpeeds[this]?.first ?: dp(defaultMarqueeSpeed)
    set(speed) {
        removeTextChangedListener(marqueeSpeeds.remove(this)?.second)
        marquee?.speed = speed
        // TODO: This should probably be defined cleaner in a class or at least outside of this setter
        val listener = object : TextWatcher {
            override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
            override fun afterTextChanged(s: Editable?) {}
            override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
                doOnNextLayout { 
                    marquee?.speed = speed
                }
            }
        }
        addTextChangedListener(listener)
        marqueeSpeeds[this] = speed to listener
    }

// Internals of Marquee speed edition below

private val marqueeSpeeds = WeakHashMap<TextView, Pair<Float, TextWatcher>>()

private inline val defaultMarqueeSpeed: Int
    get() = TextView::class.java.declaredClasses.single { it.simpleName == "Marquee" }.run {
        getDeclaredField("MARQUEE_DP_PER_SECOND").accessible.getInt(this)
    }

private typealias Marquee = Any

private inline val TextView.marquee: Marquee?
    get() = TextView::class.java
        .getDeclaredField("mMarquee")
        .accessible
        .get(this)

private inline val Marquee.speedField: Field
    get() = javaClass.getDeclaredField(
        when {
            SDK_INT > 21 -> "mPixelsPerSecond"
            else         -> "mScrollUnit"
        }
    ).accessible

private inline var Marquee.speed: Float
    get() = speedField.getFloat(this)
    set(speed) {
        speedField.setFloat(this, speed)
    }

// TODO: Maybe this shouldn't be here, or shouldn't be at all
private inline val Field.accessible: Field
    get() = apply { isAccessible = true }

Todo list:

Ribesg commented 5 years ago

The code I provided doesn't work on Android 9, I'm trying to fix it. https://github.com/aosp-mirror/platform_frameworks_base/commit/fa83834a44052fb9bbdaa81e0faea6870e71268d

LouisCAD commented 5 years ago

That's the first Android version I'd have tried to use it on 😅 Could you start by adding the try catch blocks so it becomes no-op instead of crashing? That'd allow to ensure it doesn't fail, with a mean to validate it.

LouisCAD commented 5 years ago

I just submitted a new public API request on Android's issue tracker: https://issuetracker.google.com/issues/129999621

Ribesg commented 5 years ago

FYI I tried to make a version working from API 19 to 28 and didn't find a good way to do it. But I didn't have enough time to invest in this for now