SwiftUI Snippet: Mitosis

This Snippet illustrates how you can implement a basic Mitosis effect, as seen on the Dynamic Island or throughout the new Liquid Glass design language.

While this effect is frequently approximated with a combination of a blur and an alphaThreshold filter, I've found an approach like that very hard to tune to achieve the effect that I want or to make sure shapes don't grow beyond their initial bounds.

Instead, we rely on Signed Distance Fields (again). I've only used a single Capsule Shape here but you could pass an enum value per element to the Shader and select a different SDF.

Mitosis.swift

import Charts
import simd
import SwiftUI

struct MitosisEffectContainer<Content: View>: View {
  /// The spacing between the mitosis elements.
  ///
  /// Elements close than this value will connect.
  var spacing: CGFloat

  /// The color of the mitosis material.
  var color: Color

  @ViewBuilder var content: Content

  init(spacing: CGFloat?, color: Color = .black, @ViewBuilder content: () -> Content) {
    self.spacing = spacing ?? 10
    self.color = color
    self.content = content()
  }

  var body: some View {
    content.backgroundPreferenceValue(MitosisElement.Key.self) { properties in
      GeometryReader { geometry in
        // Resolve the Anchor< CGRect> to CGRects in this coordinate
        // space.
        let resolved = properties
          .map { $0.resolve(in: geometry) }

        // Convert them to data and unpack them in Metal.
        let shader = resolved.withUnsafeBufferPointer { resolved in
          ShaderLibrary.Mitosis(
            .float(spacing),
            .color(color),
            .data(Data(buffer: resolved)),
          )
        }

        // Draw the Mitosis elements using the shader.
        Rectangle().fill(shader)
      }
      .geometryGroup()
    }
  }
}

// An individual element that can be drawn with a Mitosis effect.
struct MitosisElement {
  struct Key: PreferenceKey {
    static var defaultValue: [MitosisElement] = []

    static func reduce(value: inout Value, nextValue: () -> Value) {
      value.append(contentsOf: nextValue())
    }
  }

  // An element with its frame resolved in a specific coordinate space.
  struct Resolved {
    // x, y, width, height
    var frame: SIMD4<Float>
  }

  var frame: Anchor<CGRect>

  func resolve(in geometry: GeometryProxy) -> Resolved {
    let f = geometry[frame]

    return Resolved(frame: [
      Float(f.minX), Float(f.minY), Float(f.width), Float(f.height)
    ])
  }
}

struct MitosisEffect: ViewModifier {
  func body(content: Content) -> some View {
    content.anchorPreference(key: MitosisElement.Key.self, value: .bounds) {
      [.init(frame: $0)]
    }
  }
}

extension View {
  func mitosisEffect() -> some View {
    modifier(MitosisEffect())
  }
}

/// A `ScrollView` that extracts a refresh control from the Dynamic Island using a Mitosis effect.
struct MitosisScrollView<Content: View>: View {
  @ViewBuilder var content: Content

  @State var scrollOffset: CGFloat = 0

  // Whether the two Mitosis elements should be connected.
  //
  // For a convincing sticky fluid effects, it is important to not reconnect
  // the elements until they have touched again.
  @State var isConnected: Bool = true

  @State var isRefreshing: Bool = false

  @State var scrollPosition: ScrollPosition = .init(edge: .top)

  // The size of the sensor housing – use the `iPhone 16 Pro` simulator
  // for this effect to work.
  //
  // In production, we would chose the appropriate size and dimension for
  // the current device here.
  let sensorHousingSize = CGSize(width: 126, height: 36)

  let sensorHousingOffset: CGFloat = 14.5

  var body: some View {
    let resolvedOffset = isRefreshing ? 60 : scrollOffset

    let progress = resolvedOffset / 60

    ScrollView {
      content
        // I've found this pattern to animate more relialy whenn letting
        // go of the `ScrollView` compared to
        // `.safeAreaPadding(.top, 60)`.
        .safeAreaInset(edge: .top, spacing: 0) {
          if isRefreshing {
            Color.clear.frame(height: 60)
          }
        }
    }
    .overlay(alignment: .top) {
      MitosisEffectContainer(spacing: isConnected && !isRefreshing ? sensorHousingSize.height : 0) {
        ZStack {
          // Sensor Housing
          Rectangle()
            .fill(.clear)
            .frame(width: sensorHousingSize.width, height: sensorHousingSize.height)
            .mitosisEffect()

          refreshControl
            .opacity(2.0 * (progress - 0.5))
            .blur(radius: 9 * pow(1 - 2 * (progress - 0.5), 2))
            .mitosisEffect()
            .scaleEffect(0.8 + progress * 0.2)
            .offset(y: resolvedOffset)
        }
        .padding(.bottom, 100)
      }
      // Increase size of the `MitosisEffectContainer` sufficiently to
      // fit the offset and scale of the `refreshControl`
      .padding(.bottom, -100)
      .allowsHitTesting(false)
      .padding(.top, sensorHousingOffset)
      .edgesIgnoringSafeArea(.top)
    }
    .scrollPosition($scrollPosition)
    .onScrollGeometryChange(for: CGFloat.self) { geometry in
      let offset = -(geometry.contentOffset.y + geometry.contentInsets.top)

      return max(0, min(offset, 60))
    } action: { _, newValue in
      scrollOffset = newValue
    }
    .onScrollPhaseChange { old, new in
      if old == .interacting && new != .interacting && !isConnected {
        withAnimation {
          isRefreshing = true
        }

        Task { [$isRefreshing] in
          try? await Task.sleep(for: .seconds(6))

          withAnimation {
            $isRefreshing.wrappedValue = false
          }
        }
      }
    }
    .onChange(of: scrollOffset) { _, newValue in
      // Detatch
      if newValue > sensorHousingSize.height * 1.5 {
        withAnimation(.smooth(duration: 0.8)) {
          isConnected = false
        }
      }

      // Reattach
      if newValue <= 30 {
        withAnimation(.smooth) {
          isConnected = true
        }
      }
    }
  }

  // A simple refresh control
  var refreshControl: some View {
    Text("💭")
      .imageScale(.large)
      .keyframeAnimator(initialValue: 1.0, repeating: isRefreshing) { view, value in
        view.scaleEffect(value)
      } keyframes: { _ in
        MoveKeyframe(1)
        CubicKeyframe(0.97, duration: 0.2)
        SpringKeyframe(1, spring: .bouncy(extraBounce: 0.2), startVelocity: 3)
      }
      .foregroundStyle(.white)
      .frame(width: sensorHousingSize.height, height: sensorHousingSize.height)
  }
}

struct MitosisButton<Content: View>: View {
  @State var isOn: Bool = false

  @State var buttonSpacing: CGFloat = 36

  @ViewBuilder var content: (Bool) -> Content

  var body: some View {
    MitosisEffectContainer(spacing: buttonSpacing, color: .pink.mix(with: .black, by: 0.1)) {
      HStack {
        ZStack {
          content(isOn)
        }
        .fontWeight(.semibold)
        .padding(.horizontal)
        .frame(height: 44)
        .mitosisEffect()
        .geometryGroup()

        Image(systemName: "heart.fill")
          .animation(.smooth.speed(1.5)) {
            $0
              .opacity(isOn ? 1 : 0)
              .blur(radius: isOn ? 0 : 5)
          }
          .frame(width: 44, height: 44)
          .mitosisEffect()
          .animation(.bouncy) {
            $0
              .scaleEffect(isOn ? 1 : 0.8)
              .offset(x: isOn ? 0 : -54)
          }
      }
      .imageScale(.large)
      .foregroundStyle(.white)
      .onTapGesture {
        if isOn {
          withAnimation(.bouncy) {
            buttonSpacing = 36
          }
        } else {
          withAnimation(.bouncy.delay(0.23)) {
            buttonSpacing = 0
          }
        }

        withAnimation(.bouncy) {
          isOn.toggle()
        }
      }
      .padding(.trailing, 16)
      .padding(40)
    }
    .padding(-40)
    .padding(.trailing, -16)
    .offset(x: isOn ? 0 : 24)
    .accessibilityAddTraits([.isButton, .isToggle])
    .accessibilityAddTraits(isOn ? .isSelected : [])
  }
}

#Preview {
  MitosisScrollView {
    VStack(alignment: .leading, spacing: 12) {
      Text("SwiftUI Snippets").font(.caption.weight(.medium))
        .textCase(.uppercase).tracking(0.2)
        .foregroundStyle(.secondary)

      Text("Mitosis").font(.title.weight(.semibold))
        .padding(.bottom)

      Text("""
      Mitosis is an effect that resembles the division of cells or the separation of viscous liquid droplets. You can observe it in the animations that drive the Dynamic Island or through the new Liquid Glass design language on iOS 26.
      """)

      KeyframeAnimator(initialValue: 0, repeating: true) { spacing in
        GlassEffectContainer(spacing: spacing) {
          HStack {
            Text("Hello")
              .frame(width: 64, height: 44)
              .glassEffect(.regular.tint(.cyan))

            Text("World")
              .frame(width: 64, height: 44)
              .glassEffect(.regular.tint(.cyan))
          }
        }
        .foregroundStyle(.white)
      } keyframes: { _ in
        LinearKeyframe(0, duration: 0.5)
        CubicKeyframe(40, duration: 1)
        LinearKeyframe(40, duration: 0.5)
        CubicKeyframe(0, duration: 1)
      }
      .frame(maxWidth: .infinity)

      Text("""
      A common approach to achieving this effect is a combination of a `blur` and an `alphaThreshold` filter. For example, like so:
      """)

      KeyframeAnimator(initialValue: 0, repeating: true) { spacing in
        Canvas { context, size in
          let bounds = CGRect(origin: .zero, size: CGSize(width: 64, height: 44))

          let a = bounds.applying(
            .identity.translatedBy(x: size.width / 2 - 8 - 64, y: 10)
          )
          let b = bounds.applying(
            .identity.translatedBy(x: size.width / 2 + 8 + 0, y: 10)
          )

          context.addFilter(.alphaThreshold(min: 0.33, color: .blue))
          context.addFilter(.blur(radius: spacing))

          context.drawLayer { context in
            context.fill(Path(roundedRect: a, cornerRadius: 22), with: .foreground)
            context.fill(Path(roundedRect: b, cornerRadius: 22), with: .foreground)
          }
        }
        .frame(height: 64)
      } keyframes: { _ in
        LinearKeyframe(0, duration: 2)
        CubicKeyframe(20, duration: 1)
        LinearKeyframe(20, duration: 2)
        CubicKeyframe(0, duration: 1)
      }

      Text("""
      However, I personally find this approach incredibly tedious to tune, especially to ensure that dialing up the spacing value doesn't increase the size of individual shapes.
      """)

      Text("""
      This Snippet illustrates a different approach of using Signed Distance Fields that are merged using a smooth minimum function instead.
      """)

      Text("To see it in action, try pulling on this `ScrollView` or the the Button below \(Image(systemName: "hand.tap.fill")):")

      MitosisButton { isOn in
        Text("Thank you!").hidden()
        Text(isOn ? "Thank you!" : "Subscribe")
      }
      .frame(maxWidth: .infinity)
    }
    .frame(maxWidth: .infinity, alignment: .leading)
  }
  .background(Color(white: 0.96))
  .safeAreaPadding()
}

#Preview("Mitosis Contact Patch") {
  KeyframeAnimator(initialValue: 0, repeating: true) { spacing in
    MitosisEffectContainer(spacing: spacing) {
      VStack {
        Text("Contact")
          .frame(width: 128, height: 44)
          .mitosisEffect()

        Text("Patch")
          .frame(width: 128, height: 44)
          .mitosisEffect()
      }
      .padding(32)
    }
    .padding(-32)
    .foregroundStyle(.white)

    MitosisEffectContainer(spacing: spacing) {
      HStack {
        Text("Element\nOverlap")
          .frame(width: 104, height: 62)
          .mitosisEffect()

        Text("👋")
          .padding(12)
          .mitosisEffect()
          .offset(x: -16)
      }
      .padding(32)
    }
    .padding(-32)
    .foregroundStyle(.white)

    MitosisEffectContainer(spacing: spacing) {
      let element = Text("Stack")
        .frame(width: 64, height: 44)
        .mitosisEffect()

      ZStack {
        element; element; element; element
      }
      .padding(32)
    }
    .padding(-32)
    .foregroundStyle(.white)

    MitosisEffectContainer(spacing: spacing) {
      HStack {
        Text("One")
          .frame(width: 64, height: 44)
          .mitosisEffect()
          .offset(x:  72)

        Text("Two")
          .frame(width: 64, height: 44)
          .mitosisEffect()
          .offset(x: -72)

        Text("Three")
          .frame(width: 64, height: 44)
          .mitosisEffect()
          .offset(x:  72)

        Text("Four")
          .frame(width: 64, height: 44)
          .mitosisEffect()
          .offset(x: -72)
      }
      .padding(32)
    }
    .padding(-32)
    .foregroundStyle(.white)

    MitosisEffectContainer(spacing: spacing) {
      HStack {
        Text("Diagonal")
          .frame(width: 128, height: 44)
          .mitosisEffect()
          .padding(.bottom, 24)

        Text("Mitosis")
          .frame(width: 128, height: 44)
          .mitosisEffect()
          .padding(.top, 24)
      }
      .padding(32)
    }
    .padding(-32)
    .foregroundStyle(.white)
  } keyframes: { _ in
    LinearKeyframe(0, duration: 0.5)
    CubicKeyframe(45, duration: 1)
    LinearKeyframe(45, duration: 0.5)
    CubicKeyframe(0, duration: 1)
  }
  .frame(maxWidth: .infinity)
}

#Preview("Spacing") {
  @Previewable @State var spacing: Double = 20.0

  VStack {
    MitosisEffectContainer(spacing: 40) {
      HStack(spacing: spacing) {
        Text("Variable")
          .frame(width: 128, height: 44)
          .mitosisEffect()

        Text("Spacing")
          .frame(width: 128, height: 44)
          .mitosisEffect()
      }
      .foregroundStyle(.white)
    }
  }

  GroupBox {
    Slider(value: $spacing, in: -40 ... 40)
  }
}

#Preview("smin") {
  @Previewable @State var k: Double = 50.0

  var a: (Double) -> Double = {
    -1.0 * $0
  }

  var b: (Double) -> Double = {
    1.0 * $0
  }

  var smin: (Double, Double, Double) -> Double = { a, b, k in
    let k = k * 4.0
    let h = max(k - abs(a - b), 0.0)
    let m = 0.25 * h * h / k
    return min(a, b) - m
  }

  VStack {
    Chart {
      LinePlot(x: "x", y: "a") { x in a(x) }
        .foregroundStyle(by: .value("a", "y=a"))

      LinePlot(x: "x", y: "b") { x in b(x) }
        .foregroundStyle(by: .value("b", "y=b"))

      LinePlot(x: "x", y: "b") { x in smin(a(x), b(x), k) }
        .foregroundStyle(LinearGradient(colors: [.green, .blue], startPoint: .leading, endPoint: .trailing))

      PointPlot(
        [(0.0, k)],
        x: .value("x", \.0),
        y: .value("y", \.1)
      )
      .foregroundStyle(.green.mix(with: .blue, by: 0.5))
    }
    .chartXScale(domain: -100 ... 100)
    .chartYScale(domain: -100 ... 100)
    .aspectRatio(1, contentMode: .fit)

    GroupBox {
      Slider(value: $k, in: -100 ... 100)
    }
  }
  .safeAreaPadding()
}

Mitosis.metal

#include <metal_stdlib>
using namespace metal;

typedef struct {
  float4 frame;
} MitosisElements;

//// A smooth min function courtesy of https://iquilezles.org/articles/smin/
float3 smin(float3 a, float3 b, float k ) {
  k *= 4.0;
  float h = max(k-abs(a.x - b.x), 0.0);
  float m = 0.25 * h * h / k;
  float n = 0.50 * h / k;
  return float3(min(a.x,  b.x) - m,
         mix(a.yz, b.yz, select(n, 1.0 - n, a.x < b.x)));
}

/// The smooth distance gradient of a circular capsule.
///
/// The `x` component contains the distance to the shape, the `yz` components
/// contain the 2D gradient.
static float3 sdgCircularCapsule(float2 position, float2 bounds) {
  float r = min(bounds.x, bounds.y);
  float2 a = float2(r - bounds.x, 0.0);
  float2 b = -a;
  float2 ba = b - a;
  float2 pa = position - a;
  float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
  float2 q = pa - h * ba;
  return float3(length(q) - r, normalize(q));
}

[[stitchable]]
half4 Mitosis(float2 position, float spacing, half4 color, device const void *elementPointer, int elementSize) {
  int count = elementSize / sizeof(MitosisElements);

  if (count == 0) return 0;

  auto elements = (device const MitosisElements*)elementPointer;
  auto first = elements[0];

  // The current distance from `position` to the nearest shape.
  float3 gradient = sdgCircularCapsule(position - first.frame.xy - first.frame.zw / 2, first.frame.zw / 2);

  for (int i = 1; i < count; i++) {
    auto e = elements[i];

    // Calculate a Signed Distance Gradient inside the frame of the element.
    //
    // TODO: Use different SDGs like continuous capsules, rects, etc.
    auto shapeGradient = sdgCircularCapsule(position - e.frame.xy - e.frame.zw / 2, e.frame.zw / 2);

    // A scale factor for the `k` value passed to the `smin` function.
    //
    // Two shape borders that face each other merge sooner than two that
    // don't.
    auto s = -dot(shapeGradient.yz, gradient.yz);
    auto k = spacing / 4 * s;

    gradient = smin(gradient, shapeGradient, k + 0.0001);
  }

  if (gradient.x < 0) {
    return mix(0, color, saturate(-gradient.x * 2));
  }

  return 0;
}

To smoothly blend the different distance fields, I use a smooth minimum function – another technique that Inigo Quilzes has written about (do consider subscribing to his Patreon if you haven't already).

The k value that controls the ramp between the two values is scaled by the dot product of the two shapes' gradients – this creates more pleasing transitions between capsules as the flat sides that are axis-aligned now blob together more readily than the curves parts that face away from each other – check out the Mitosis Contact Patch Xcode Preview for examples.

I encourage you to play around with the effect – I've found a lot of fun can be had by modulating the k value in interesting ways, such as using a second SDF that models individual strings of material between the shapes – Liquid Cheese anyone?

(Note that this Snippet requires a recent Xcode beta only for its exemplary use of the GlassEffectContainer – the shader code shown here runs on iOS 17 and above – that's 9 iOSes ago!)

Thank you for your continuing support and for sticking with me through the extended hiatus.

Robb