Building a Day/Night Sky Gradient System for Android Live Wallpapers

Creating Realistic Sky Gradients That Change With the Sun

When you look at a great live wallpaper, what makes it feel alive isn’t just animation — it’s the sky. A static blue-to-orange gradient at the top of your screen feels dead after five minutes. But a sky that actually shifts through dawn blues, golden hours, twilight purples, and star-filled blacks? That’s the difference between a wallpaper you tolerate and one you actually admire. Building an android day night sky gradient system is the first step toward making your live wallpaper respond to the real world outside the phone.

In Seasons Live Wallpaper, the sky gradient system is the foundation everything else builds on — the weather effects layer over it, the moon and stars positioned against it, the day/night cycle that drives the entire app. Let me walk you through how we built it.

Step 1: Calculate Sun Position Using TwilightCalculator

Android provides a hidden gem in the framework called TwilightCalculator. It lives in android.app.TwilightState (on Android 14+) or in the AndroidX libraries, and it handles the math of where the sun is at any given location and time. You give it your latitude, longitude, and current time in milliseconds, and it tells you:

  • Sunrise time
  • Sunset time
  • Whether it’s currently day, night, or twilight
  • The sun’s exact position (as a ratio from -1 to 1, where 0 is solar noon)

Here’s a practical Kotlin wrapper to query this efficiently:

import android.app.TwilightState
import kotlin.math.abs

class SkyCalculator(private val latitude: Double, private val longitude: Double) {

    fun calculateSunPosition(timeMillis: Long): SunPosition {
        val calculator = TwilightState().apply {
            // Requires API 31+ or use AndroidX wrapper
            // For older APIs, use: org.shredzone.commons.suncalc.SunTimes
        }
        
        // Calculate which phase we're in (dawn, golden hour, noon, sunset, twilight, night)
        val phase = getSunPhase(timeMillis)
        val sunRatio = getSunRatioForTime(timeMillis)  // -1.0 to 1.0
        
        return SunPosition(
            phase = phase,
            sunRatio = sunRatio,
            timeMillis = timeMillis
        )
    }
    
    private fun getSunPhase(timeMillis: Long): SunPhase {
        // You'll populate this by querying sunrise/sunset from TwilightState
        // For now, placeholder logic
        val hour = (timeMillis / 3600000L) % 24
        return when {
            hour in 4.5..6.0 -> SunPhase.DAWN
            hour in 6.0..9.0 -> SunPhase.GOLDEN_HOUR_MORNING
            hour in 9.0..17.0 -> SunPhase.DAY
            hour in 17.0..18.5 -> SunPhase.GOLDEN_HOUR_EVENING
            hour in 18.5..20.0 -> SunPhase.SUNSET
            hour in 20.0..22.0 -> SunPhase.TWILIGHT_ASTRONOMICAL
            else -> SunPhase.NIGHT
        }
    }
    
    private fun getSunRatioForTime(timeMillis: Long): Float {
        // Sunrise at -1.0, solar noon at 0.0, sunset at 1.0
        return (abs(((timeMillis / 3600000L) % 24) - 12.0) / 12.0 - 1.0).toFloat()
    }
}

data class SunPosition(val phase: SunPhase, val sunRatio: Float, val timeMillis: Long)

enum class SunPhase {
    DAWN,
    GOLDEN_HOUR_MORNING,
    DAY,
    GOLDEN_HOUR_EVENING,
    SUNSET,
    TWILIGHT_NAUTICAL,
    TWILIGHT_ASTRONOMICAL,
    NIGHT
}

The real production code queries the system’s actual sunrise/sunset times, but the key insight is that the sun’s position can be expressed as a ratio — and that ratio drives everything else.

Step 2: Define Sky Gradient Keyframes for Each Phase

Once you know which phase you’re in, you need to define the color gradients that belong to that phase. We store these as simple data structures — an array of colors that represent the gradient from top to bottom:

data class SkyGradient(
    val topColor: Int,
    val horizonColor: Int,
    val phase: SunPhase,
    val duration: Long  // How long this phase lasts, in seconds
)

object SkyPalette {
    // Colors from top of screen to horizon
    val dawn = SkyGradient(
        topColor = 0xFF1a1a3e,      // Deep navy
        horizonColor = 0xFFff9b5a,  // Warm orange-red
        phase = SunPhase.DAWN,
        duration = 90 * 60  // 90 minutes
    )
    
    val goldenHourMorning = SkyGradient(
        topColor = 0xFF87ceeb,      // Sky blue
        horizonColor = 0xFFffb347,  // Golden yellow
        phase = SunPhase.GOLDEN_HOUR_MORNING,
        duration = 60 * 60
    )
    
    val day = SkyGradient(
        topColor = 0xFF87ceeb,      // Clear sky blue
        horizonColor = 0xFFe0f6ff,  // Pale horizon
        phase = SunPhase.DAY,
        duration = 480 * 60  // 8 hours
    )
    
    val goldenHourEvening = SkyGradient(
        topColor = 0xFF87ceeb,
        horizonColor = 0xFFff7f50,  // Coral sunset
        phase = SunPhase.GOLDEN_HOUR_EVENING,
        duration = 60 * 60
    )
    
    val sunset = SkyGradient(
        topColor = 0xFF1f1c48,      // Deep purple
        horizonColor = 0xFFff6b4a,  // Fiery red
        phase = SunPhase.SUNSET,
        duration = 90 * 60
    )
    
    val twilight = SkyGradient(
        topColor = 0xFF0a0e27,      // Almost black
        horizonColor = 0xFF1a3a52,  // Deep blue
        phase = SunPhase.TWILIGHT_ASTRONOMICAL,
        duration = 60 * 60
    )
    
    val night = SkyGradient(
        topColor = 0xFF0a0a0f,      // Black
        horizonColor = 0xFF1a1a2e,  // Very dark blue
        phase = SunPhase.NIGHT,
        duration = 600 * 60  // 10 hours
    )
}

These values come from real observations — shoot photos of actual skies during these times and sample the colors with a picker. The more accurate, the better the wallpaper will feel.

Step 3: Interpolate Between Phases With Smooth Transitions

The real magic happens when you interpolate colors as you transition from one phase to the next. Instead of hard-cutting from sunset to twilight, you blend the gradients smoothly over 10 or 15 minutes. Here’s the interpolation logic:

import android.graphics.Color
import kotlin.math.clamp

class SkyGradientInterpolator {
    
    fun getCurrentGradient(
        sunPosition: SunPosition,
        transitionDurationMs: Long = 15 * 60 * 1000  // 15 minute transitions
    ): SkyGradient {
        val currentPhase = sunPosition.phase
        val nextPhase = getNextPhase(currentPhase)
        val currentGradient = getPaletteForPhase(currentPhase)
        val nextGradient = getPaletteForPhase(nextPhase)
        
        // Calculate progress through transition: 0.0 to 1.0
        val transitionProgress = getTransitionProgress(
            sunPosition.timeMillis,
            currentPhase,
            transitionDurationMs
        )
        
        // Clamp to 0-1; if not in transition, use current gradient as-is
        val t = clamp(transitionProgress, 0f, 1f)
        
        return SkyGradient(
            topColor = interpolateColor(currentGradient.topColor, nextGradient.topColor, t),
            horizonColor = interpolateColor(currentGradient.horizonColor, nextGradient.horizonColor, t),
            phase = currentPhase,
            duration = currentGradient.duration
        )
    }
    
    private fun interpolateColor(from: Int, to: Int, t: Float): Int {
        val fromA = (from shr 24) and 0xFF
        val fromR = (from shr 16) and 0xFF
        val fromG = (from shr 8) and 0xFF
        val fromB = from and 0xFF
        
        val toA = (to shr 24) and 0xFF
        val toR = (to shr 16) and 0xFF
        val toG = (to shr 8) and 0xFF
        val toB = to and 0xFF
        
        val a = (fromA + (toA - fromA) * t).toInt()
        val r = (fromR + (toR - fromR) * t).toInt()
        val g = (fromG + (toG - fromG) * t).toInt()
        val b = (fromB + (toB - fromB) * t).toInt()
        
        return (a shl 24) or (r shl 16) or (g shl 8) or b
    }
    
    private fun getPaletteForPhase(phase: SunPhase): SkyGradient = when (phase) {
        SunPhase.DAWN -> SkyPalette.dawn
        SunPhase.GOLDEN_HOUR_MORNING -> SkyPalette.goldenHourMorning
        SunPhase.DAY -> SkyPalette.day
        SunPhase.GOLDEN_HOUR_EVENING -> SkyPalette.goldenHourEvening
        SunPhase.SUNSET -> SkyPalette.sunset
        SunPhase.TWILIGHT_ASTRONOMICAL -> SkyPalette.twilight
        SunPhase.NIGHT -> SkyPalette.night
        else -> SkyPalette.day
    }
    
    private fun getNextPhase(current: SunPhase): SunPhase = when (current) {
        SunPhase.DAWN -> SunPhase.GOLDEN_HOUR_MORNING
        SunPhase.GOLDEN_HOUR_MORNING -> SunPhase.DAY
        SunPhase.DAY -> SunPhase.GOLDEN_HOUR_EVENING
        SunPhase.GOLDEN_HOUR_EVENING -> SunPhase.SUNSET
        SunPhase.SUNSET -> SunPhase.TWILIGHT_ASTRONOMICAL
        SunPhase.TWILIGHT_ASTRONOMICAL -> SunPhase.NIGHT
        SunPhase.NIGHT -> SunPhase.DAWN
    }
    
    private fun getTransitionProgress(timeMillis: Long, phase: SunPhase, duration: Long): Float {
        // Your app should track when the last phase change occurred
        // and use that to calculate how far into a transition we are
        return 0.5f  // Placeholder
    }
}

Notice the interpolateColor function — it breaks each color into ARGB components, interpolates each channel linearly, and reassembles the result. This gives you smooth, natural-looking transitions without harsh color shifts.

Step 4: Render the Gradient and Layer in Moon Phases & Stars

Once you have the gradient, rendering it is straightforward — draw it on a Canvas as a vertical gradient, then layer celestial objects on top:

import android.graphics.Canvas
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Shader

class SkyRenderer(val width: Int, val height: Int) {
    
    fun drawSky(canvas: Canvas, gradient: SkyGradient) {
        val paint = Paint(Paint.ANTI_ALIAS_FLAG)
        val shader = LinearGradient(
            0f, 0f,          // Start at top
            0f, height.toFloat(),  // End at bottom
            intArrayOf(gradient.topColor, gradient.horizonColor),
            floatArrayOf(0f, 1f),
            Shader.TileMode.CLAMP
        )
        paint.shader = shader
        canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
    }
    
    fun drawMoonPhase(canvas: Canvas, moonPosition: MoonData, sunPhase: SunPhase) {
        // Only draw moon at night
        if (sunPhase != SunPhase.NIGHT && sunPhase != SunPhase.TWILIGHT_ASTRONOMICAL) {
            return
        }
        
        val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
            color = 0xFFe8e8e8.toInt()  // Off-white
        }
        
        val moonRadius = 60f
        val moonX = (width * 0.15f)
        val moonY = (height * 0.2f)
        
        // Draw moon circle
        canvas.drawCircle(moonX, moonY, moonRadius, paint)
        
        // Calculate moon phase (new, waxing, full, waning) based on moonPosition.illumination
        val shadowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
            color = 0xFF0a0a0f.toInt()  // Same as night sky
        }
        val shadowWidth = moonRadius * 2 * (1f - moonPosition.illumination)
        if (shadowWidth > 0) {
            canvas.drawRect(
                moonX - moonRadius, moonY - moonRadius,
                moonX - moonRadius + shadowWidth, moonY + moonRadius,
                shadowPaint
            )
        }
    }
    
    fun drawStars(canvas: Canvas, starField: List, sunPhase: SunPhase) {
        // Stars fade in at twilight, peak brightness at night, fade out at dawn
        val starOpacity = when (sunPhase) {
            SunPhase.TWILIGHT_ASTRONOMICAL -> 200  // fading in
            SunPhase.NIGHT -> 255                   // full brightness
            SunPhase.DAWN -> 100                    // fading out
            else -> 0
        }
        
        if (starOpacity == 0) return
        
        val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
            color = 0xFFffffff.toInt()
            alpha = starOpacity
        }
        
        for (star in starField) {
            canvas.drawCircle(star.x, star.y, star.radius, paint)
        }
    }
}

data class Star(val x: Float, val y: Float, val radius: Float, val brightness: Float)
data class MoonData(val illumination: Float)  // 0.0 (new) to 1.0 (full)

This approach layers the components cleanly: first the gradient base, then the moon (if visible), then the stars (with dynamic opacity based on the phase). The separation keeps each visual element manageable.

Linking to Related Concepts

Once your gradient system is working, the next logical step is to add weather effects on top of it. You’ll find that link in our guide to building a weather-reactive rendering engine. For inspiration on real-world particle systems and animation patterns, check out our deep dive on animating the seasons with particle systems. And if you want to see this all in action, the Seasons Live Wallpaper showcase shows how these systems come together in a finished app.

For precise timing of all these calculations, you’ll want to use Kotlin’s efficient timing APIs — explore measureTimedValue and Duration to keep your render loop performant.

External Resources for Deeper Learning

For the astronomy math behind sun and moon calculations, the definitive resource is Android’s developer documentation. For color science and gradient interpolation, Kotlin’s standard library gives you everything you need for performant math.

Summary

A great day/night sky gradient system for Android live wallpapers rests on three pillars: accurate sun position calculation, carefully crafted gradient keyframes for each phase of the day, and smooth interpolation between them. Build those three pieces and layer celestial objects on top, and you’ll have a sky that doesn’t just look beautiful — it feels alive. Your users will notice the difference between a static gradient and one that breathes with the real world outside their phone.


This post was written by a human with the help of Claude, an AI assistant by Anthropic.

Scroll to Top