I'm trying to create a tutorial framework in SwiftUI that finds a specific view and highlights it by darkening the rest of the screen.
For example: Say I have three circles...
And I want to highlight the blue one...
Here's what I have come up with so far.
This works, but I need the size and location of the blue circle, in order to know where to place the mask.
In order to achieve this, I have to write some hacky code with GeometryReader
. Ie: Create a geometry reader inside of the blue circle's overlay modifier and return a clear background. This allows me to retrieve the dynamic size and location of the view. If I just wrapped the blue circle in a normal GeometryReader
statement it would remove the dynamic size and position of the view.
Lastly I store the frame
of the blue circle, and set the frame
and position
of the mask using it, thus achieving what I want, a cutout over the top of the blue circle in the dark overlay.
All this being said I'm getting a runtime error "Modifying state during view update, this will cause undefined behavior."
Also approach seems very hack and sticky. Ideally I'd like to create a separate framework where I could target a view, then add an overlay view with a specific shape cut out in order to highlight the specific view.
Here's the code from the above example:
@State var blueFrame: CGRect = .zero
var body: some View {
ZStack {
VStack {
Circle()
.fill(Color.red)
.frame(width: 100, height: 100)
ZStack {
Circle()
.fill(Color.blue)
.frame(width: 100, height: 100)
.overlay {
GeometryReader { geometry -> Color in
let geoFrame = geometry.frame(in: .global)
blueFrame = CGRect(x: geoFrame.origin.x + (geoFrame.width / 2),
y: geoFrame.origin.y + (geoFrame.height / 2),
width: geoFrame.width,
height: geoFrame.height)
return Color.clear
}
}
}
Circle()
.fill(Color.green)
.frame(width: 100, height: 100)
}
Color.black.opacity(0.75)
.edgesIgnoringSafeArea(.all)
.reverseMask {
Circle()
.frame(width: blueFrame.width + 10, height: blueFrame.height + 10)
.position(blueFrame.origin)
}
.ignoresSafeArea()
}
}
I think you want something like this:
One way to achieve this is using matchedGeometryEffect
to put the spotlight over the selected light, and to use blendMode
and compositingGroup
to cut the hole in the darkening overlay.
First, let's define a type to track which light is selected:
enum Light: Hashable, CaseIterable {
case red
case yellow
case green
var color: Color {
switch self {
case .red: return .red
case .yellow: return .yellow
case .green: return .green
}
}
}
Now we can write a View
that draws the colored lights. Each light is modified with matchedGeometryEffect
to make its frame available for use by the spotlighting view (to be written later).
struct LightsView: View {
let namespace: Namespace.ID
var body: some View {
VStack(spacing: 20) {
ForEach(Light.allCases, id: \.self) { light in
Circle()
.foregroundColor(light.color)
.matchedGeometryEffect(
id: light, in: namespace,
properties: .frame, anchor: .center,
isSource: true
)
}
}
.padding(20)
}
}
Here's the spotlighting view. It uses blendMode(.destinationOut)
on a Circle
to cut that circle out of the underlying Color.black
, and uses compositingGroup
to contain the blending to just the Circle
and the Color.black
.
struct SpotlightView: View {
var spotlitLight: Light
var namespace: Namespace.ID
var body: some View {
ZStack {
Color.black
Circle()
.foregroundColor(.white)
.blur(radius: 4)
.padding(-10)
.matchedGeometryEffect(
id: spotlitLight, in: namespace,
properties: .frame, anchor: .center,
isSource: false
)
.blendMode(.destinationOut)
}
.compositingGroup()
}
}
In HighlightingView
, put the SpotlightView
over the LightsView
and animate the SpotlightView
:
struct HighlightingView: View {
var spotlitLight: Light
var isSpotlighting: Bool
@Namespace private var namespace
var body: some View {
ZStack {
LightsView(namespace: namespace)
SpotlightView(
spotlitLight: spotlitLight,
namespace: namespace
)
.opacity(isSpotlighting ? 0.5 : 0)
.animation(
.easeOut,
value: isSpotlighting ? spotlitLight : nil
)
}
}
}
Finally, ContentView
tracks the selection state and adds the Picker
:
struct ContentView: View {
@State var isSpotlighting = false
@State var spotlitLight: Light = .red
private var selection: Binding<Light?> {
Binding(
get: { isSpotlighting ? spotlitLight : nil },
set: {
if let light = $0 {
isSpotlighting = true
spotlitLight = light
} else {
isSpotlighting = false
}
}
)
}
var body: some View {
VStack {
HighlightingView(
spotlitLight: spotlitLight,
isSpotlighting: isSpotlighting
)
Picker("Light", selection: selection) {
Text("none").tag(Light?.none)
ForEach(Light.allCases, id: \.self) {
Text("\($0)" as String)
.tag(Optional($0))
}
}
.pickerStyle(.segmented)
}
.padding()
}
}