Skip to content

Instantly share code, notes, and snippets.

@sofakingforever
Last active February 3, 2022 09:21
Show Gist options
  • Save sofakingforever/24507173b7743784303ea1bbf8e9e6bb to your computer and use it in GitHub Desktop.
Save sofakingforever/24507173b7743784303ea1bbf8e9e6bb to your computer and use it in GitHub Desktop.
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>
@sofakingforever
Copy link
Author

Don't forget to call the view's onStart() and onStop() from their respective Activity's lifecycle methods.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment