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:
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)
}
}
}