Exploring Design Dynamics with SwiftUI and Jetpack Compose

Explore the blend of design and coding in our latest project using SwiftUI for iOS and Jetpack Compose for Android.

I grew up immersed in the world of graphic design, surrounded by magazines, posters, and flyers. During that era, digital devices were scarce, and access to computers was limited. The computers of the time were primitive, offering only text interfaces without the visual user interfaces we are accustomed to today. One aspect that made print media so appealing was the sense of freedom it offered. Ironically, ever since I ventured into digital design, my goal has been to recreate the look and feel of those printed materials that originally inspired me.

Another passion of mine lies in bringing my ideas to life through software development. This involves the journey from a mere concept to a tangible design, and finally, to a functional product or prototype. A significant challenge for me was my limited coding skills; it was an area where I consistently struggled, yet I persevered.

Thankfully, advancements like GenAI have simplified the process, making it easier for me to experiment with my ideas. In this post, I am excited to share my latest experiment where I played with the phone's gyroscope to create unique and dynamic compositions inspired by images like this:

Artifact from Exploring Design Dynamics with SwiftUI and Jetpack Compose article on AbduzeedoArtifact from Exploring Design Dynamics with SwiftUI and Jetpack Compose article on AbduzeedoArtifact from Exploring Design Dynamics with SwiftUI and Jetpack Compose article on Abduzeedo

In this video, you will see the experiment in action. The first part demonstrates the application for the iPhone, using SwiftUI, while the second part showcases its adaptation for Android using Jetpack Compose.

For those interested in learning more about how I achieved this or if you're inclined to try it yourself, I've included the code for both the SwiftUI and Jetpack Compose versions:

SwiftUI

//
//  ContentView.swift
//  Tunnel
//
//  Created by Fabio Sasso on 1/5/24.
//
import SwiftUI
import CoreMotion
struct ContentView: View {
    @State private var motionManager = CMMotionManager()
    @State private var initialAttitude: CMAttitude?
    @State private var xTilt: CGFloat = 0.0
    @State private var yTilt: CGFloat = 0.0
    @State private var xRotationRate: CGFloat = 0.0
    @State private var yRotationRate: CGFloat = 0.0
    @State private var xRotation: CGFloat = 0.0
    @State private var yRotation: CGFloat = 0.0
    @State private var xRotationOffset: CGFloat = 0.0
    @State private var yRotationOffset: CGFloat = 0.0
    let numberOfCircles = 6
    
    let timer = Timer.publish(every: 1/60, on: .main, in: .common).autoconnect()
    
    var body: some View {
        GeometryReader { geometry in
            ZStack{
                VStack{
                    HStack{
                        Text("Gyro Tunnel").font(.title2).bold().foregroundColor(.black).opacity(0.9)
                        Spacer()
                        Text("Experiments").font(.title3).foregroundColor(.black).opacity(0.9)
                    }
                    Spacer()
                    HStack{
                        Text("Steale Labs").font(.caption).monospaced().foregroundColor(.black).opacity(0.9)
                        Spacer()
                        Text("2024").font(.caption).monospaced().foregroundColor(.black).opacity(0.9)
                    }
                }.zIndex(/*@START_MENU_TOKEN@*/1.0/*@END_MENU_TOKEN@*/).padding(24)
                ZStack {
                    // This is the fixed largest circle at the center
                    Circle()
                        .fill(Color.gray.opacity(0.4))
                        .blendMode(.multiply)
                        .frame(width: geometry.size.width, height: geometry.size.width)
                    
                    // These are the moving circles
                    ForEach(1..<numberOfCircles, id: \.self) { i in
                        Circle()
                            .fill(Color.black.opacity(0.2))
                            .frame(width: geometry.size.width * (1 - CGFloat(i) * 0.45 / CGFloat(numberOfCircles - 1)))
                            .offset(x: self.xRotationOffset * (CGFloat(i)), y: self.yRotationOffset * (CGFloat(i)))
                    }
                }
                .frame(width: geometry.size.width, height: geometry.size.height)
                .background(Color.mint)
                .onAppear() {
                    self.startGyros()
                }
                .onReceive(timer) { _ in
                    if self.motionManager.deviceMotion != nil {
                        self.updateRotationOffsets()
                    }
                }
            }
        }
    }
    
    func startGyros() {
        if motionManager.isDeviceMotionAvailable {
            self.motionManager.deviceMotionUpdateInterval = 1.0 / 60.0
            self.motionManager.startDeviceMotionUpdates(using: .xArbitraryZVertical)
            
            // Delay to ensure we have valid data
            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                if let data = self.motionManager.deviceMotion {
                    // Capture the initial device attitude
                    self.initialAttitude = data.attitude.copy() as? CMAttitude
                }
            }
        }
    }
    func updateRotationOffsets() {
        guard let currentAttitude = self.motionManager.deviceMotion?.attitude, let initialAttitude = self.initialAttitude else {
            return
        }
        
        // Get the current attitude relative to the initial one
        currentAttitude.multiply(byInverseOf: initialAttitude)
        
        // Scale down the rotation values for a more subtle effect
        let scaleFactor: CGFloat = 10
        self.xRotationOffset = CGFloat(currentAttitude.roll) * scaleFactor
        self.yRotationOffset = CGFloat(currentAttitude.pitch) * scaleFactor
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
#Preview {
    ContentView()
}

Jetpack Compose

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
        setContent {
            StealeLabsGyroTunnelTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.primary
                ) {
                    GyroTunnelView(sensorManager = sensorManager)
                }
            }
        }
    }
}
@Composable
fun GyroTunnelView(sensorManager: SensorManager) {
    val myColorScheme = MaterialTheme.colorScheme
    // State variables to store cumulative motion data
    var xCumulativeOffset by remember { mutableStateOf(0f) }
    var yCumulativeOffset by remember { mutableStateOf(0f) }
    val gyroscope = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
    val listener = object : SensorEventListener {
        override fun onSensorChanged(event: SensorEvent) {
            // Incrementally update the offsets
            val gyroX = event.values[0]
            val gyroY = event.values[1]
            xCumulativeOffset += gyroX // Adjust the increment scale as needed
            yCumulativeOffset += gyroY // Adjust the increment scale as needed
        }
        override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
    }
    DisposableEffect(sensorManager) {
        sensorManager.registerListener(listener, gyroscope, SensorManager.SENSOR_DELAY_GAME)
        onDispose {
            sensorManager.unregisterListener(listener)
        }
    }
    // Handle unregistration of the listener
    DisposableEffect(Unit) {
        onDispose {
            sensorManager.unregisterListener(listener)
        }
    }
    // UI Layout
    Chrome()
    Column(modifier = Modifier.fillMaxSize()) {
        // Canvas for drawing circles
        Canvas(modifier = Modifier.fillMaxSize()) {
            val numberOfCircles = 6
            val canvasWidth = size.width
            val canvasHeight = size.height
            val canvasCenter = Offset(canvasWidth / 2, canvasHeight / 2) // Center point of the Canvas
            // Draw the fixed largest circle at the center
            val largestCircleRadius = minOf(canvasWidth, canvasHeight) / 2
            drawCircle(Color.Gray.copy(alpha = 0.4f), radius = largestCircleRadius, center = canvasCenter,blendMode = BlendMode.Multiply,)
            for (i in 1 until numberOfCircles) {
                val radius = largestCircleRadius * (1 - i * 0.45f / (numberOfCircles - 1))
                drawCircle(
                    color = Color.Black.copy(alpha = 0.3f),
                    blendMode = BlendMode.Multiply,
                    radius = radius,
                    center = canvasCenter + Offset(yCumulativeOffset/2 * i, xCumulativeOffset/2 * i)
                )
            }
        }
    }
}
@Composable
fun Chrome() {
    Column(
        modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp),
    ) {
        Row(modifier = Modifier
            .fillMaxWidth()
            .height(56.dp)){
            Text("Gyro Tunnel", style = MaterialTheme.typography.headlineSmall)
            Spacer(Modifier.weight(1f))
            Text("Experiment", style = MaterialTheme.typography.headlineSmall)
        }
        Spacer(Modifier.weight(1f))
        Row(modifier = Modifier
            .fillMaxWidth()
            .height(48.dp)
        ){
            Text("Steale Labs", style = MaterialTheme.typography.bodyMedium)
            Spacer(Modifier.weight(1f))
            Text("2024", style = MaterialTheme.typography.bodyMedium)
        }
    }
}

Brought to you by