Fooling around with masks & shadows in SwiftUI
Masks are a fun thing. No, not these masks, although they do seem fun too. No, rendering masks. The kind you find in Photoshop, Blender, Maya, and Final Cut Pro. What is a mask? It's a way to edit an image using another image.
One example would be masking an image with some text, giving that text a dashing style.
But we can't do much more. SwiftUI masking takes into account the alpha of the masking view, allowing us to "cut" and "punch through" our views to reveal what's underneath (basically, the reverse of what Paul did in the link above), and render it in real-time at runtime. Allowing us to do this:
Now, there is a cost to playing around with masks and shadows. A rendering cost. Often in the form of off-screen rendering. That is, views rendered only as intermediate steps, and discarded without ever being shown, in computing the actual final rendering of the view that will end up being displayed. This can occur, for example, when drawing shadows, hence the strong suggestion to use shadowPaths
.
Offscreen rendering can take a toll on a device, and lead to stuttering, frame drops, and overall poor performance. Especially when using table views, collection views, lazy stacks and lazy grids.
It also occurs when using masks. And here, we're using both masks and shadows. So keep that in mind when using what we'll learn here today.
Setup
To build the example above, we'll need a LazyVGrid
, and a view for the items displayed. Since this isn't a post about gradients, we'll start with it already set.
import SwiftUI
public struct ItemView: View {
public struct ViewModel: Identifiable {
public let id: UUID
let name: String
}
let viewModel: ViewModel
public var body: some View {
RoundedRectangle(cornerRadius: 8)
.overlay {
Text(viewModel.name)
.font(.system(size: 64, weight: .black))
.background(.white)
}
}
}
public struct ItemsListView: View {
let items: [ItemView.ViewModel]
public var body: some View {
LinearGradient(
colors: [.red, .blue, .orange],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea(.container)
.overlay {
GeometryReader { geometry in
LazyVGrid(columns: [.init(.adaptive(minimum: geometry.size.width / 3 - 15))]) {
ForEach(items) { item in
ItemView(viewModel: item)
.frame(height: geometry.size.width / 3 - 15)
.padding(5)
}
}
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
}
}
@main
struct testApp: App {
let items = (1...9).map { index in ItemView.ViewModel(id: UUID(), name: String(index)) }
var body: some Scene {
WindowGroup {
ItemsListView(items: items)
}
}
}
Which looks like this:
The background
First, we'll add a semi-transparent background, using materials.
public struct ItemView: View {
public struct ViewModel: Identifiable {
public let id: UUID
let name: String
}
let viewModel: ViewModel
public var body: some View {
RoundedRectangle(cornerRadius: 8)
.fill(.ultraThinMaterial)
.overlay {
Text(viewModel.name)
.font(.system(size: 64, weight: .black))
}
}
}
Border shadow
Now, if you look back at the original image, you can see the cells "popped" from the background. That's thanks to a shadow. Let's add it.
public struct ItemView: View {
public struct ViewModel: Identifiable {
public let id: UUID
let name: String
}
let viewModel: ViewModel
public var body: some View {
RoundedRectangle(cornerRadius: 8)
.fill(.ultraThinMaterial)
.overlay {
Text(viewModel.name)
.font(.system(size: 64, weight: .black))
}
.shadow(radius: 2, x: 2, y: 2)
}
}
Masking
Oh, that looks great! Now, let's add our mask, and "punch through" our cell.
public struct ItemView: View {
public struct ViewModel: Identifiable {
public let id: UUID
let name: String
}
let viewModel: ViewModel
public var body: some View {
RoundedRectangle(cornerRadius: 8)
.fill(.ultraThinMaterial)
.mask {
Text(viewModel.name)
.font(.system(size: 64, weight: .black))
.foregroundColor(.black)
}
.shadow(radius: 2, x: 2, y: 2)
}
}
Yeah. No. That's not what we were aiming for. Ok, as an advanced take-home project, I'll leave it up to you, reader, to figure out...
... just kidding.
Ok, so, what's wrong here?
compositingGroup()
First, our Text
isn't treated as a mask. For it to be, we need to rasterise it, using .compositingGroup
.
public struct ItemView: View {
public struct ViewModel: Identifiable {
public let id: UUID
let name: String
}
let viewModel: ViewModel
public var body: some View {
RoundedRectangle(cornerRadius: 8)
.fill(.ultraThinMaterial)
.mask {
Text(viewModel.name)
.font(.system(size: 64, weight: .black))
.foregroundColor(.black)
.compositingGroup()
}
.shadow(radius: 2, x: 2, y: 2)
}
}
Better. Our text now masks our blurry semi-transparent rounded rectangle. But that's not it, it is? We're masking the rectangle to our text, which looks cute, but we aren't punching our text through the background. How do we do that?
Inverted masking and luminance
What we need is a mask telling us to keep everything but our text. An inverted mask. A mask where everything is there except the space taken up by our text.
Masking, in SwiftUI, relies on the mask's alpha, or transparency. A mask area with an alpha of 0 will be removed that area from the masked view. A mask area with an alpha of 1 will keep the masked view's corresponding area intact.
But there's another to mask: using luminance. How bright or dark an area is. Which can then be converted to an alpha, using .luminanceToAlpha()
.
public struct ItemView: View {
public struct ViewModel: Identifiable {
public let id: UUID
let name: String
}
let viewModel: ViewModel
public var body: some View {
RoundedRectangle(cornerRadius: 8)
.fill(.ultraThinMaterial)
.mask {
RoundedRectangle(cornerRadius: 8)
.fill(.white)
.overlay {
Text(viewModel.name)
.font(.system(size: 64, weight: .black))
.foregroundColor(.black)
}
.compositingGroup()
.luminanceToAlpha()
}
.shadow(radius: 2, x: 2, y: 2)
}
}
Much better, right? Thanks, Vlad. I would have spent quite a bit of time on it otherwise.
All of it put together
import SwiftUI
public struct ItemView: View {
public struct ViewModel: Identifiable {
public let id: UUID
let name: String
}
let viewModel: ViewModel
public var body: some View {
RoundedRectangle(cornerRadius: 8)
.fill(.ultraThinMaterial)
.mask {
RoundedRectangle(cornerRadius: 8)
.fill(.white)
.overlay {
Text(viewModel.name)
.font(.system(size: 64, weight: .black))
.foregroundColor(.black)
}
.compositingGroup()
.luminanceToAlpha()
}
.shadow(radius: 2, x: 2, y: 2)
}
}
public struct ItemsListView: View {
let items: [ItemView.ViewModel]
public var body: some View {
LinearGradient(
colors: [.red, .blue, .orange],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea(.container)
.overlay {
GeometryReader { geometry in
LazyVGrid(columns: [.init(.adaptive(minimum: geometry.size.width / 3 - 15))]) {
ForEach(items) { item in
ItemView(viewModel: item)
.frame(height: geometry.size.width / 3 - 15)
.padding(5)
}
}
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
}
}
Taking it further
There are quite a lot of really nice things to do with that. The first example that comes to mind? Cards, with a blurred overlay, and the underlying picture showing through a cut-out rounded rectangle. Like this:
Isn't it gorgeous? I'll let you figure it out—a hint: multiple layers, and lots of masks.
We could also determine how bright or dark the picture is, or use the system dark mode or the background colour to adjust the inner shadows' colours dynamically. But that'll be for another time.
Epilogue
So, here you are. A, I hope, nice, short and simple introduction to combining masks and shadows in SwiftUI. Please keep in mind that although masks and shadows, inverted masks and inner shadows in particular, can help create gorgeous effects, they can have a significant impact on our apps' performances.
As always, please do reach out should you have any questions or improvements to suggest, and have a great!