Skip to content

Instantly share code, notes, and snippets.

@pbk20191
Last active February 16, 2025 11:41
Show Gist options
  • Save pbk20191/77c3800f36205cbdd157324a0f414e91 to your computer and use it in GitHub Desktop.
Save pbk20191/77c3800f36205cbdd157324a0f414e91 to your computer and use it in GitHub Desktop.
Spine Android Native Drawable
package com.esotericsoftware.spine.android;
import android.graphics.Canvas
import com.badlogic.gdx.utils.Array
import com.esotericsoftware.spine.android.SkeletonRenderer.RenderCommand
// this is the hack for accessing package-private while in ART & Kotlin
// ART does not have module.info.java so we can safely access it at runtime
// we can access java-package scope at compile time thanks to kotlin
// kotlin + ART(no module.info.java) -> below class can access everything!
data object KTExtensionSpineCompat {
fun getPaint(
command: RenderCommand
) = command.texture.getPaint(command.blendMode)
fun commandUV(command: RenderCommand) = command.uvs
fun commandVertice(command: RenderCommand) = command.vertices
fun commandColors(command: RenderCommand) = command.colors
fun commandIndices(command: RenderCommand) = command.indices
}
import android.animation.TimeAnimator
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.PixelFormat.TRANSPARENT
import android.graphics.Rect
import android.graphics.Region
import android.graphics.drawable.Animatable
import android.graphics.drawable.Animatable2
import android.graphics.drawable.Drawable
import android.os.Build
import androidx.core.animation.doOnEnd
import androidx.core.animation.doOnStart
import com.badlogic.gdx.utils.Array
import com.esotericsoftware.spine.android.AndroidSkeletonDrawable
import com.esotericsoftware.spine.android.KTExtensionSpineCompat
import com.esotericsoftware.spine.android.SkeletonRenderer
import com.esotericsoftware.spine.android.SkeletonRenderer.RenderCommand
import com.esotericsoftware.spine.android.SpineController
import com.esotericsoftware.spine.android.bounds.Alignment
import com.esotericsoftware.spine.android.bounds.Bounds
import com.esotericsoftware.spine.android.bounds.BoundsProvider
import com.esotericsoftware.spine.android.bounds.ContentMode
import com.esotericsoftware.spine.android.bounds.SetupPoseBounds
import kotlin.properties.Delegates
class SpineDrawable(
drawable: AndroidSkeletonDrawable,
): Drawable(), Animatable, Animatable2, TimeAnimator.TimeListener {
private val animationCb = ArrayList<Animatable2.AnimationCallback>()
private var offsetX = 0.0f;
private var offsetY = 0.0f;
private var scaleX = 1.0f
private var scaleY = 1.0f
private var x = 0.0f
private var y = 0.0f
private val computedBounds = Bounds();
private val renderer = SkeletonRenderer()
private var colorFilter: ColorFilter? = null
private var alpha = 255;
private val _controller:SpineDrawableController = SpineDrawableController(drawable)
private val clock = _controller.clock
val controller: SpineController get() = this._controller
public var alignment by Delegates.observable(Alignment.CENTER) { prop, old, new ->
updateCanvasTransform()
}
public var contentMode by Delegates.observable(ContentMode.FIT) { prop, old, new ->
updateCanvasTransform()
}
public var boundProvider: BoundsProvider by Delegates.observable(SetupPoseBounds()) { prop, old, new ->
val other = new.computeBounds(this.controller.drawable)
this.computedBounds.x = other.x
this.computedBounds.y = other.y
this.computedBounds.width = other.width
this.computedBounds.height = other.height
updateCanvasTransform()
}
init {
val other = boundProvider.computeBounds(controller.drawable)
this.computedBounds.x = other.x
this.computedBounds.y = other.y
this.computedBounds.width = other.width
this.computedBounds.height = other.height
clock.setTimeListener(this)
clock.doOnStart {
animationCb.forEach {
it.onAnimationStart(this)
}
}
clock.doOnEnd {
animationCb.forEach{
it.onAnimationEnd(this)
}
}
}
override fun draw(canvas: Canvas) {
val saved = if (alpha < 255) {
canvas.saveLayerAlpha(bounds.left.toFloat(),bounds.top.toFloat(), bounds.right.toFloat(), bounds.bottom.toFloat(), alpha)
} else {
canvas.save()
}
canvas.translate(offsetX, offsetY);
canvas.scale(scaleX, scaleY * -1);
canvas.translate(x, y);
_controller.callOnBeforePaint(canvas)
val commands = renderer.render(controller.getSkeleton());
// if color filter support is not needed, this commented single line of code is enough
// renderer.renderToCanvas(canvas, commands);
val myFilter = this.colorFilter
commands.forEach { command ->
// TODO: better way to inject Color Filter
val paint = KTExtensionSpineCompat.getPaint(command)
val oldFilter = paint.colorFilter
if (myFilter!= null) {
paint.colorFilter = myFilter
}
// TODO: contribute to Spine-runtime directly rather than using hacks
val vertices = KTExtensionSpineCompat.commandVertice(command)
val uvs = KTExtensionSpineCompat.commandUV(command)
val indices = KTExtensionSpineCompat.commandIndices(command)
val colors = KTExtensionSpineCompat.commandColors(command)
if (Build.VERSION.SDK_INT >= 29) {
canvas.drawVertices(
Canvas.VertexMode.TRIANGLES, vertices.size, vertices.items, 0, uvs.items,
0, colors.items, 0, indices.items, 0, indices.size,
paint);
} else {
// See https://github.com/EsotericSoftware/spine-runtimes/issues/2638
val colors = colors.items;
val colorsCopy = IntArray(size = vertices.size) //new int[command.vertices.size];
System.arraycopy(colors, 0, colorsCopy, 0, colors.size);
canvas.drawVertices(Canvas.VertexMode.TRIANGLES, vertices.size, vertices.items, 0, uvs.items,
0, colorsCopy, 0, indices.items, 0, indices.size, paint);
}
if (myFilter != null) {
paint.colorFilter = oldFilter
}
}
_controller.callOnAfterPaint(canvas, commands)
canvas.restoreToCount(saved)
}
override fun setAlpha(alpha: Int) {
this.alpha = alpha
}
override fun getAlpha(): Int {
return this.alpha
}
override fun onBoundsChange(bounds: Rect) {
super.onBoundsChange(bounds)
updateCanvasTransform()
}
override fun getIntrinsicHeight(): Int {
return this.computedBounds.height.toInt()
}
override fun getIntrinsicWidth(): Int {
return this.computedBounds.width.toInt()
}
private fun updateCanvasTransform () {
x = (-computedBounds.x.toFloat() - computedBounds.width.toFloat() / 2.0f
- (alignment.x.toFloat() * computedBounds.width.toFloat() / 2.0f));
y = (-computedBounds.y - computedBounds.height / 2.0f
- (alignment.y * computedBounds.height / 2.0f)).toFloat();
when (contentMode) {
ContentMode.FIT -> {
scaleX = (bounds.width() / computedBounds.width).coerceAtMost(bounds.height() / computedBounds.height)
.toFloat()
scaleY = scaleX
}
ContentMode.FILL -> {
scaleX = (bounds.width() / computedBounds.width).coerceAtLeast(bounds.height() / computedBounds.height)
.toFloat()
scaleY = scaleX
}
}
offsetX = (bounds.width() / 2.0 + (alignment.x * bounds.width() / 2.0)).toFloat()
offsetY = (bounds.height() / 2.0 + (alignment.y * bounds.height() / 2.0)).toFloat()
_controller.setCoordinateTransform(
x + offsetX / scaleX.toDouble(), y + offsetY / scaleY.toDouble(), scaleX.toDouble(), scaleY.toDouble()
)
}
override fun start() {
_controller.superResume()
clock.start()
}
override fun stop() {
controller.pause()
clock.cancel()
}
public fun resume() {
_controller.superResume()
clock.resume()
}
public fun pause() {
_controller.superPause()
clock.pause()
}
override fun isRunning(): Boolean {
return clock.isRunning
}
override fun registerAnimationCallback(callback: Animatable2.AnimationCallback) {
animationCb.add(callback)
}
override fun unregisterAnimationCallback(callback: Animatable2.AnimationCallback): Boolean {
return animationCb.remove(callback)
}
override fun clearAnimationCallbacks() {
animationCb.clear()
}
override fun setColorFilter(colorFilter: ColorFilter?) {
this.colorFilter = colorFilter
}
override fun getColorFilter(): ColorFilter? {
return this.colorFilter
}
override fun getOpacity(): Int {
return TRANSPARENT
}
// TODO: return transparent region
override fun getTransparentRegion(): Region? {
return super.getTransparentRegion()
}
override fun onTimeUpdate(animation: TimeAnimator, totalTime: Long, deltaTime: Long) {
_controller.worldTransform {
controller.getDrawable().update(deltaTime.toFloat() / 1e3f);
}
invalidateSelf()
if (callback == null || !controller.isPlaying) {
this.clock.pause()
}
}
override fun setVisible(visible: Boolean, restart: Boolean): Boolean {
val changed = super.setVisible(visible, restart)
if (changed) {
if (visible) {
_controller.superResume()
if (restart) {
clock.start()
} else {
if (clock.isStarted) {
clock.resume()
} else {
clock.start()
}
}
} else {
clock.pause()
}
}
return changed
}
private open class SpineDrawableController(drawable: AndroidSkeletonDrawable): SpineController({}) {
init {
this.init(drawable)
}
public val clock = TimeAnimator()
public override fun callOnBeforePaint(canvas: Canvas) {
super.callOnBeforePaint(canvas)
}
public override fun callOnAfterPaint(canvas: Canvas, commands: Array<RenderCommand>) {
super.callOnAfterPaint(canvas, commands)
}
fun worldTransform( block: () -> Unit) {
this.callOnBeforeUpdateWorldTransforms()
block()
this.callOnAfterUpdateWorldTransforms()
}
public override fun setCoordinateTransform(
offsetX: Double,
offsetY: Double,
scaleX: Double,
scaleY : Double) {
super.setCoordinateTransform(offsetX, offsetY, scaleX, scaleY)
}
public fun superResume() {
super.resume()
}
public fun superPause() {
super.pause()
}
override fun resume() {
super.resume()
if (clock.isStarted) {
clock.resume()
} else {
clock.start()
}
}
override fun pause() {
super.pause()
clock.pause()
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment