Crafting Interactive Tiles in SwiftUI
Hejka! Today we are going to build an interactive gradient grid. The effect combines SwiftUI’s MeshGradient
with gesture handling and Metal-based grain texture.
The source code for this effect is available at the end of the article.
Build the Basic Grid
We are going to start with a simple grid of black tiles with n columns and m rows. Each tile has size, between tiles there is a spacing.
In SwiftUI there is the Grid
view which perfectly fits our needs. It allows us to create a grid of items with a specified number of columns and rows.
Take note of the offset
calculation in the code below. It ensures that the grid is centered within the available space. We calculate how much empty space remains on each horizontal and vertical axis after accounting for the grid’s total width and height. You can adjust it to your needs if you integrate this view somewhere.
import SwiftUI
struct InteractiveTiles: View {
let rows = 6
let columns = 8
let tileSize: CGFloat = 48
let spacing: CGFloat = 2
var gridSize: CGSize {
CGSize(
width: CGFloat(columns) * tileSize + CGFloat(columns - 1) * spacing,
height: CGFloat(rows) * tileSize + CGFloat(rows - 1) * spacing
)
}
var body: some View {
GeometryReader { geometry in
let offset = CGPoint(
x: (geometry.size.width - gridSize.width) / 2,
y: (geometry.size.height - gridSize.height) / 2
)
Grid(
alignment: .center,
horizontalSpacing: spacing,
verticalSpacing: spacing
) {
ForEach(0..<rows, id: \.self) { row in
GridRow {
ForEach(0..<columns, id: \.self) { col in
Rectangle()
.frame(width: tileSize, height: tileSize)
}
}
}
}
.position(
x: offset.x + gridSize.width / 2,
y: offset.y + gridSize.height / 2
)
}
}
}
Compiling and running the code should give this basic grid with black tiles.

Introduce Gesture Interaction
Now let’s add the interactive part. The idea is pretty simple - we need to track the touch position and animate the tiles’ corner radius based on distance between the touch position and the tile center. The further the tile is from the touch position, the less rounded its corners are. To avoid affecting the whole grid, we maintain the “influence zone” parameter - the maximum distance from the touch position after which the tile’s corners have a default value.
I have a separate article where I used similar approach to implement a ripple effect. You can check it out here.
Here we start from introducing a couple of state variables to track the drag position and the list of corner radii for each tile in the grid. Other variables are helpful shortcuts that we are going to use in corner radius calculation. Adjusting maxCornerRadius
and minCornerRadius
we can control the range of corner radius values.
struct InteractiveTiles: View {
@State private var dragPosition: CGPoint = .zero
@State private var cornerRadii: [[CGFloat]] = []
var squareStep: CGFloat { tileSize + spacing }
var halfTile: CGFloat { tileSize / 2 }
var maxCornerRadius: CGFloat { 0.5 }
var minCornerRadius: CGFloat { 0.2 }
var radiusRange: CGFloat { maxCornerRadius - minCornerRadius }
...
}
In order not to clutter the main content view, we move the grid implementation into a separate structure Notice that here we already apply the corner radius to the tile.
struct GridMaskView: View {
let rows: Int
let columns: Int
let tileSize: CGFloat
let spacing: CGFloat
let offset: CGPoint
let gridSize: CGSize
let cornerRadii: [[CGFloat]]
let minCornerRadius: CGFloat
var body: some View {
Grid(
alignment: .center,
horizontalSpacing: spacing,
verticalSpacing: spacing
) {
ForEach(0..<rows, id: \.self) { row in
GridRow {
ForEach(0..<columns, id: \.self) { col in
let cornerRadius = makeCornerRadius(row: row, col: col)
Rectangle()
.cornerRadius(cornerRadius * tileSize)
.frame(width: tileSize, height: tileSize)
.animation(.easeOut(duration: 0.15), value: cornerRadius)
}
}
}
}
.position(
x: offset.x + gridSize.width / 2,
y: offset.y + gridSize.height / 2
)
}
private func makeCornerRadius(row: Int, col: Int) -> CGFloat {
guard
0..<cornerRadii.count ~= row,
0..<cornerRadii[row].count ~= col
else {
return minCornerRadius
}
return cornerRadii[row][col]
}
}
Next, we implement the logic for calculating the corner radii. Note the influenceDistance
- it defines the influence zone we were talking about earlier.
We iterate over the grid and calculate the distance from the touch position to the tile center. In normalizedDistance
we have value from 0.0 to 1.0 which represents how far the tile is from the touch position in relative terms. To keep the rule “far tiles are less rounded”, we calculate 1.0 - normalizedDistance
and multiply it by the range of corner radius values.
struct InteractiveTiles: View {
...
private func updateCornerRadii(geometry: GeometryProxy) {
let offset = CGPoint(
x: (geometry.size.width - gridSize.width) / 2,
y: (geometry.size.height - gridSize.height) / 2
)
let influenceDistance: CGFloat = 200
for (row, rowArray) in cornerRadii.enumerated() {
for (col, _) in rowArray.enumerated() {
let squarePosition = CGPoint(
x: offset.x + CGFloat(col) * tileSize + halfTile,
y: offset.y + CGFloat(row) * tileSize + halfTile
)
let distance = dragPosition.distance(to: squarePosition)
let clampedDistance = min(distance, influenceDistance)
let normalizedDistance = clampedDistance / influenceDistance
let cornerRadius = minCornerRadius + radiusRange * (1.0 - normalizedDistance)
cornerRadii[row][col] = cornerRadius
}
}
}
private func resetCornerRadii() {
for (row, rowArray) in cornerRadii.enumerated() {
for (col, _) in rowArray.enumerated() {
cornerRadii[row][col] = 0.2
}
}
}
}
extension CGPoint {
func distance(to point: CGPoint) -> CGFloat {
let dx = self.x - point.x
let dy = self.y - point.y
return sqrt(dx * dx + dy * dy)
}
}
Now it’s time to update the main content view body. Add the gesture modifier to the grid: when touch changes we update the corner radii and when touch ends we simply reset it to the default value.
struct InteractiveTiles: View {
...
var body: some View {
GeometryReader { geometry in
let offset = CGPoint(
x: (geometry.size.width - gridSize.width) / 2,
y: (geometry.size.height - gridSize.height) / 2
)
GridMaskView(
rows: rows,
columns: columns,
tileSize: tileSize,
spacing: spacing,
offset: offset,
gridSize: gridSize,
cornerRadii: cornerRadii,
minCornerRadius: minCornerRadius
)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
dragPosition = value.location
updateCornerRadii(geometry: geometry)
}
.onEnded { _ in
resetCornerRadii()
}
)
.onAppear {
guard cornerRadii.isEmpty else { return }
cornerRadii = Array(
repeating: Array(repeating: minCornerRadius, count: columns),
count: rows
)
}
}
}
}
One more compile and run. Now you should be able to drag your finger across the screen and see the tiles’ corner radius changing.
Paint the Grid
Let’s bring some colors to the grid. There are plenty of ways to do this, but we are going to use the MeshGradient
view.
struct InteractiveTiles: View {
...
var body: some View {
GeometryReader { geometry in
let offset = CGPoint(
x: (geometry.size.width - gridSize.width) / 2,
y: (geometry.size.height - gridSize.height) / 2
)
MeshGradient(
width: 4, height: 3,
points: [
[0.00, 0.0], [0.33, 0.0], [0.66, 0.0], [1.00, 0.0],
[0.00, 0.42], [0.24, 0.38], [0.78, 0.39], [1.00, 0.44],
[0.00, 1.00], [0.26, 1.00], [0.40, 1.00], [1.00, 1.00]
],
colors: [
Color(red: 18/255, green: 20/255, blue: 74/255),
Color(red: 110/255, green: 0/255, blue: 170/255),
Color(red: 1.00, green: 0.26, blue: 0.68),
Color(red: 1.00, green: 0.23, blue: 0.48),
Color(red: 0.98, green: 0.20, blue: 0.60),
Color(red: 1.00, green: 0.55, blue: 0.10),
Color(red: 1.00, green: 0.80, blue: 0.40),
Color.white.opacity(0.85),
Color(red: 12/255, green: 15/255, blue: 54/255),
Color(red: 80/255, green: 60/255, blue: 220/255),
Color(red: 93/255, green: 24/255, blue: 120/255),
Color(red: 8/255, green: 12/255, blue: 60/255),
]
)
.frame(width: gridSize.width, height: gridSize.height)
.position(
x: geometry.size.width / 2,
y: geometry.size.height / 2
)
.mask(
GridMaskView(
rows: rows,
columns: columns,
tileSize: tileSize,
spacing: spacing,
offset: offset,
gridSize: gridSize,
cornerRadii: cornerRadii,
minCornerRadius: minCornerRadius
)
)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
dragPosition = value.location
updateCornerRadii(geometry: geometry)
}
.onEnded { _ in
resetCornerRadii()
}
)
.onAppear {
guard cornerRadii.isEmpty else { return }
cornerRadii = Array(
repeating: Array(repeating: 0.2, count: columns),
count: rows
)
}
}
}
}
Compile, run, boom - we have a gradient grid. Here you can play with the gradient colors and points, or even use TimelineView
to animate it.
Texture the Grid
At this step I wanted to push the result a little bit further and bring some texture to the gradient. Ended up with the grain effect implementation through Metal shader.
It uses a simple noise function to generate black and white pixels.
#include <metal_stdlib>
using namespace metal;
[[ stitchable ]]
half4 noiseShader(
float2 position,
half4 color,
float2 size
) {
float noise = fract(sin(dot(position, float2(32.235, 23.542))) * 52123.512);
return half4(half3(noise), 1.0) * color.a;
}
Here is how it looks like. By changing the constants or the nested functions in this noise function it’s possible to achieve different patterns.

To use it in SwiftUI we need to use .visualEffect
. For convenience we wrap it into a separate view modifier. Notice that we have .overlay
where we place content with opacity applied to it. This allows us to make the noise effect be visible and blend with the gradient. Alternatively there are other ways to achieve this for example by playing with blending modes.
struct GrainEffect: ViewModifier {
let opacity: CGFloat
func body(content: Content) -> some View {
content
.visualEffect { content, proxy in
content
.colorEffect(
ShaderLibrary.noiseShader(
.float2(proxy.size)
)
)
}
.overlay {
content
.opacity(opacity)
}
}
}
extension View {
func grain(opacity: CGFloat) -> some View {
modifier(GrainEffect(opacity: opacity))
}
}
Apply the .grain
modifier right after the MeshGradient
view.
MeshGradient(
...
)
.grain(opacity: 0.88)
.frame(width: gridSize.width, height: gridSize.height)
.position(
x: geometry.size.width / 2,
y: geometry.size.height / 2
)
.mask(
...
)
Another compile and run. Now your grid should have a fancy grain effect.
Conclusion
One more experiment is done. I personally really liked this simple implementation of the grain texture through the noise function. You can develop this idea further and, for example, instead of a gesture, use an animation of the effect’s start point.
Here is the gist with the source code.
See you 🦄