Last active
October 20, 2025 11:33
-
-
Save uvolchyk/92f214f28c910c57e9a62dcc969496af to your computer and use it in GitHub Desktop.
Source code for the article: https://uvolchyk.me/blog/crafting-interactive-tiles-in-swiftui
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| ) | |
| } | |
| @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 } | |
| 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), | |
| ] | |
| ) | |
| .grain(opacity: 0.88) | |
| .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 | |
| ) | |
| } | |
| } | |
| } | |
| 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) | |
| } | |
| } | |
| 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] | |
| } | |
| } | |
| 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)) | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
video-3.mp4