morristech
2/14/2019 - 10:11 PM

Draw animated stars on Android view canvas - written in Kotlin - crafted with ❤️ by sofakingforever

Draw animated stars on Android view canvas - written in Kotlin - crafted with ❤️ by sofakingforever

package com.sofaking.moonworshipper.view

import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.view.View
import java.util.*
import java.util.concurrent.Executors 
import kotlin.concurrent.timerTask

/**
 * Kotlin Android view that draws animated stars on a canvas
 *
 * Used in Wakey - Beautiful Alarm Clock for Android: http://bit.ly/2uI8pgL
 * Check out the article on Medium: http://bit.ly/2NlFJBW
 * Or see what it looks like on YouTube: https://www.youtube.com/watch?v=v1-228CkoQc
 *
 * Don't forget to call the view's onStart() and onStop() from their respective Activity's lifecycle methods.
 *
 * Crafted with ❤️ by sofakingforever
 */
class AnimatedStarsView
@kotlin.jvm.JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) {

    
    private val fps: Long = 1000 / 60
    private val defaultStarCount: Int = 25
    private val threadExecutor = Executors.newSingleThreadExecutor()
    
    private var starCount: Int
    private var starColors: IntArray
    private var bigStarThreshold: Int
    private var minStarSize: Int
    private var maxStarSize: Int

    private var starsCalculatedFlag: Boolean = false
    private var viewWidth: Int = 0
    private var viewHeight: Int = 0
    private var stars: ArrayList<Star> = ArrayList()
    private var starConstraints: StarConstraints


    private lateinit var timer: Timer
    private lateinit var task: TimerTask
    
    private val random: Random = Random()
    private var initiated: Boolean = false 

    /**
     * init view's attributes
     */
    init {

        val array = context.obtainStyledAttributes(attrs, R.styleable.AnimatedStarsView, defStyleAttr, 0)

        starColors = intArrayOf()
        starCount = array.getInt(R.styleable.AnimatedStarsView_starsView_starCount, defaultStarCount)
        minStarSize = array.getDimensionPixelSize(R.styleable.AnimatedStarsView_starsView_minStarSize, 4)
        maxStarSize = array.getDimensionPixelSize(R.styleable.AnimatedStarsView_starsView_maxStarSize, 24)
        bigStarThreshold = array.getDimensionPixelSize(R.styleable.AnimatedStarsView_starsView_bigStarThreshold, Integer.MAX_VALUE)
        starConstraints = StarConstraints(minStarSize, maxStarSize, bigStarThreshold)

        val starColorsArrayId = array.getResourceId(R.styleable.AnimatedStarsView_starsView_starColors, 0)

        if (starColorsArrayId != 0) {
            starColors = context.resources.getIntArray(starColorsArrayId)
        }

        array.recycle()

    }


    /**
     * Must call this in Activity's onStart
     */
    fun onStart() {

        timer = Timer()
        task = timerTask {
            invalidateStars()
        }

        timer.scheduleAtFixedRate(task, 0, fps)
    }


    /**
     * Must call this in Activity's onStop
     */
    fun onStop() {

        task.cancel()
        timer.cancel()

    }

    /**
     * get view's size and init stars every time the size of the view has changed
     */
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        viewWidth = w
        viewHeight = h

        if (viewWidth > 0 && viewHeight > 0) {
            // init stars every time the size of the view has changed
            initStars()
        }
    }


    /**
     * Draw stars on view's canvas
     */
    override fun onDraw(canvas: Canvas?) {

        // create a variable canvas object
        var newCanvas = canvas

        // draw each star on the canvas
        stars.forEach { newCanvas = it.draw(newCanvas) }

        // reset flag
        starsCalculatedFlag = false

        // finish drawing view
        super.onDraw(newCanvas)

    }


    /**
     * create x stars with a random point location and opacity
     */
    private fun initStars() {

        // map stars instead of adding via loop - courtesy of Dominik Mičuta & Arek Olek
        stars = List(starCount) {
            Star(
                    starConstraints,
                    Math.round(Math.random() * viewWidth).toInt(),
                    Math.round(Math.random() * viewHeight).toInt(),
                    Math.random(),
                    starColors[it % starColors.size],
                    viewWidth,
                    viewHeight,
                    { starColors[random.nextInt(starColors.size)] }
            )
        }
        
        initiated = true

    }

    /**
     * calculate and invalidate all stars for the next frame
     */
    private fun invalidateStars() {

        if (!initiated){ 
            return 
        } 

        // new background thread
        threadExecutor.execute({

            // recalculate stars position and alpha on a background thread
            stars.forEach { it.calculateFrame(viewWidth, viewHeight) }
            starsCalculatedFlag = true

            // then post to ui thread
            postInvalidate()

        })

    }
}

/**
 * Single star in sky view
 */
private class Star(val starConstraints: StarConstraints, var x: Int, var y: Int, var opacity: Double, var color: Int, viewWidth: Int, viewHeight: Int, val colorListener: () -> Int) {


    var alpha: Int = 0
    var factor: Int = 1
    var increment: Double
    val length: Double = (starConstraints.minStarSize + Math.random() * (starConstraints.maxStarSize - starConstraints.minStarSize))

    val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
    val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG)


    private var shape: StarShape

    private lateinit var hRect: RectF
    private lateinit var vRect: RectF

    /**
     * init paint, shape and some parameters
     */
    init {

        // init fill paint for small and big stars
        fillPaint.color = color

        // init stroke paint for the circle stars
        strokePaint.color = color
        strokePaint.style = Paint.Style.STROKE
        strokePaint.strokeWidth = length.toFloat() / 4f

        // init shape of star according to random size
        shape = if (length >= starConstraints.bigStarThreshold) {

            // big star ones will randomly be Star or Circle

            if (Math.random() < < 0.7) {
                StarShape.Star
            } else {
                StarShape.Circle
            }
        } else {
            // small ones will be dots

            StarShape.Dot
        }

        // the alpha incerment speed will be decided according to the star's size
        increment = when (shape) {
            StarShape.Circle -> {
                Math.random() * .025
            }
            StarShape.Star -> {
                Math.random() * .030
            }
            StarShape.Dot -> {
                Math.random() * .045
            }
        }

        initLocationAndRectangles(viewWidth, viewHeight)
    }

    /**
     * calculate single frame for star (factor, opacity, and location if needed)
     */
    fun calculateFrame(viewWidth: Int, viewHeight: Int) {

        // calculate direction / factor of opacity

        if (opacity >= 1 || opacity <= 0) {
            factor *= -1
        }

        // calculate new opacity for star
        opacity += increment * factor

        // convert to int-based alpha
        alpha = (opacity * 255.0).toInt()

        when {
            alpha > 255 -> {
                // reset alpha to full
                alpha = 255
            }
            alpha <= 0 -> {
                // reset alpha to 0
                alpha = 0

                // and relocate star
                initLocationAndRectangles(viewWidth, viewHeight)
                
                color = colorListener.invoke() 
 
                // init fill paint for small and big stars 
                fillPaint.color = color 
 
                // init stroke paint for the circle stars 
                strokePaint.color = color 
                strokePaint.style = Paint.Style.STROKE 
                strokePaint.strokeWidth = length.toFloat() / 4f 
            }
        }


    }


    /**
     * init star's position and rectangles if needed
     */
    private fun initLocationAndRectangles(viewWidth: Int, viewHeight: Int) {

        // randomize location

        x = Math.round(Math.random() * viewWidth).toInt()
        y = Math.round(Math.random() * viewHeight).toInt()

        // calculate rectangles for big stars

        if (shape == StarShape.Star) {

            val hLeft = (x - length / 2).toFloat()
            val hRight = (x + length / 2).toFloat()
            val hTop = (y - length / 6).toFloat()
            val hBottom = (y + length / 6).toFloat()

            hRect = RectF(hLeft, hTop, hRight, hBottom)


            val vLeft = (x - length / 6).toFloat()
            val vRight = (x + length / 6).toFloat()
            val vTop = (y - length / 2).toFloat()
            val vBottom = (y + length / 2).toFloat()

            vRect = RectF(vLeft, vTop, vRight, vBottom)
        }


    }

    internal fun draw(canvas: Canvas?): Canvas? {

        // set current alpha to paint

        fillPaint.alpha = alpha
        strokePaint.alpha = alpha

        // draw according to shape

        when (shape) {
            StarShape.Dot -> {
                canvas?.drawCircle(x.toFloat(), y.toFloat(), length.toFloat() / 2f, fillPaint)
            }
            StarShape.Star -> {
                canvas?.drawRoundRect(hRect, 6f, 6f, fillPaint)
                canvas?.drawRoundRect(vRect, 6f, 6f, fillPaint)
            }
            StarShape.Circle -> {
                canvas?.drawCircle(x.toFloat(), y.toFloat(), length.toFloat() / 2f, strokePaint)
            }
        }

        return canvas

    }

    private enum class StarShape {
        Circle, Star, Dot
    }
    
    interface Listener { 
        fun getNewColor(): Int 
 
    } 

}

private class StarConstraints(val minStarSize: Int, val maxStarSize: Int, val bigStarThreshold: Int)
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="AnimatedStarsView">
        <attr name="starsView_starCount" format="integer" />
        <attr name="starsView_starColors" format="reference" />
        <attr name="starsView_minStarSize" format="dimension"/>
        <attr name="starsView_maxStarSize" format="dimension"/>
        <attr name="starsView_bigStarThreshold" format="dimension"/>
    </declare-styleable>
</resources>
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/stars_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.sofaking.moonworshipper.view.AnimatedStarsView
        android:id="@+id/stars_big"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:starsView_bigStarThreshold="8dp"
        app:starsView_maxStarSize="16dp"
        app:starsView_minStarSize="1dp"
        app:starsView_starColors="@array/star_colors"
        app:starsView_starCount="35" />

</FrameLayout>
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <integer-array name="star_colors">
        <!-- This is how you can configure the ratio of star colors-->        
        <item>@color/star_color_1</item>
        <item>@color/star_color_1</item>
        <item>@color/star_color_1</item>
        <item>@color/star_color_1</item>
        <item>@color/star_color_2</item>
        <item>@color/star_color_3</item>
    </integer-array>
</resources>