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.

spacingtile sizegrid size

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.

Code
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.

image

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.

touchinfluencezone

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.

Code
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.

Code
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.

Code
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.

Code
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.

Code
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.

Code
#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.

noise demo

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.

Code
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.

Code
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 🦄