Say the blue rectangle is in the center of the red rectangle, and the green needed to be on top and the yellow on the left.
How would one do this in SwiftUI with alignment guides? None of the sizes are known, but the yellow and blue height match, and green and blue width match.
I got help from https://swiftui-lab.com on twitter
With my helper method:
extension View {
func overlay<Overlay: View>(alignment: Alignment, #ViewBuilder builder: () -> Overlay) -> some View {
overlay(builder(), alignment: alignment)
}
}
it can be done so:
Color.red
.overlay(alignment: .bottom) {
HStack(alignment: .top, spacing: 0) {
Color.clear.frame(width: 0)
Color.yellow
.alignmentGuide(.top) { $0.height + 10 }
}
}
.overlay(alignment: .trailing) {
VStack(alignment: .leading, spacing: 0) {
Color.clear.frame(height: 0)
Color.blue
.alignmentGuide(.leading) { $0.width + 10 }
}
}
.padding(100) // padding given for the example
Related
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.
Create a ZStack.
Place semi-transparent black background on top.
Add inverted mask to background to punch hole in it to reveal blue circle.
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()
}
}
You can do it this way. Using geometry size is not "hacky" at all. The "styling" of the circles you can do whatever you like.
//
// ContentView.swift
// SelectedCircle
//
// Created by Allan Garcia on 27/01/23.
//
import SwiftUI
enum SelectedColor {
case red
case blue
case green
case none
}
struct ContentView: View {
#State private var selectedColor: SelectedColor = .none
var body: some View {
GeometryReader { geometry in
let minSize = min(geometry.size.height, geometry.size.width) * 0.5
let offset = minSize * 1.2
ZStack {
Color.white.zIndex(-100) // background
if selectedColor != .none {
Color.black
.opacity(0.9)
.zIndex(selectedColor != .none ? 50 : 0)
.onTapGesture { selectedColor = .none }
} // Fade out
Circle()
.fill(Color.red)
.zIndex(selectedColor != .none && selectedColor == .red ? 100 : 0)
.onTapGesture { selectedColor = .red }
.offset(y: offset)
.frame(width: minSize, height: minSize)
Circle()
.fill(Color.blue)
.zIndex(selectedColor != .none && selectedColor == .blue ? 100 : 0)
.onTapGesture { selectedColor = .blue }
.frame(width: minSize, height: minSize)
Circle()
.fill(Color.green)
.zIndex(selectedColor != .none && selectedColor == .green ? 100 : 0)
.onTapGesture { selectedColor = .green }
.offset(y: -offset)
.frame(width: minSize, height: minSize)
}
.ignoresSafeArea(.all)
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Let me know if something was unclear. You can also change onTapGesture to any other gesture you want. The enum, if make sense, could be moved to your model, but I strongly believe is a state of the view, so it must live in the view.
EDIT: This will not modify the "behavior" of the view, you are changing the State, that's the most Swifty way of doing this. The change in the State will trigger the redraw of the view and the view will reflect the new State that changed.
EDIT2: Published at: https://github.com/allangarcia/SelectedCircle
Allow me to add another approach. If the size of the negative highlight can stay the same for all views, then you don't need any GeometryReader.
Of course you can also pass individual sizes to the func.
I packed the highlight func into a View extension that can conveniently be used as a view modifier.
struct ContentView: View {
#State private var highlight = 0
var body: some View {
ZStack {
VStack {
Spacer()
Circle()
.fill(.blue)
.frame(width: 100)
.negativeHighlight(enabled: highlight == 0)
Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi laoreet elementum purus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Cras vel ipsum et risus vulputate auctor non ac ligula.")
.padding()
.negativeHighlight(enabled: highlight == 1)
RoundedRectangle(cornerRadius: 10)
.fill(.red)
.frame(width: 50, height: 80)
.negativeHighlight(enabled: highlight == 2)
HStack {
Rectangle()
.fill(.green)
.frame(width: 100, height: 100)
.padding()
.negativeHighlight(enabled: highlight == 3)
Circle()
.fill(.yellow)
.frame(width: 100)
.negativeHighlight(enabled: highlight == 4)
}
Spacer()
// move to next view
Button("Show Next") { highlight = (highlight + 1) % 6 }
.buttonStyle(.borderedProminent)
.zIndex(2)
}
}
}
}
extension View {
func negativeHighlight(enabled: Bool) -> some View {
self
.overlay(
Color.black.opacity(0.5)
.reverseMask {
Circle()
.fill(.blue)
.frame(width: 150)
}
.frame(width: 10_000, height: 10_000)
.opacity(enabled ? 1 : 0)
)
.zIndex(enabled ? 1 : 0)
}
#inlinable func reverseMask<Mask: View>(
alignment: Alignment = .center,
#ViewBuilder _ mask: () -> Mask
) -> some View {
self.mask(
ZStack {
Rectangle()
mask()
.blendMode(.destinationOut)
}
)
}
}
In the code below, firstScrollProxy does not work, while secondScrollViewProxy does. I don't understand why.
The only solution I found, was to give some id to the overlay, and scroll to that. However that causes other issues for my code, and I'd rather avoid such workarounds.
I played with fixedSize() for the ZStack items, but that didn't help either.
Laying out the items vertically has the same issue, while a VStack works.
The anchor is optional, but trying different anchors does reveal the fact that the scroll view behaves as if the width of the items are the same as the entire scrollable area.
import SwiftUI
struct ContentView: View {
var body: some View {
let numItems: Int = 100
let itemWidth = 60.0
let itemHeight = 100.0
VStack(spacing: 4) {
Spacer()
ScrollViewReader { firstScrollProxy in
ScrollView(.horizontal) {
ZStack {
ForEach(0..<numItems, id:\.self) { x in
Rectangle()
.fill(Color.purple)
.frame(width: itemWidth - 2, height: itemHeight)
.overlay {
Text("\(x)")
}
.position(x: Double(x) * itemWidth + itemWidth / 2.0, y: itemHeight / 2.0)
}
}
.frame(width: Double(numItems) * itemWidth, height: itemHeight)
}
.onTapGesture {
withAnimation {
firstScrollProxy.scrollTo(17, anchor: .center)
}
}
}
.padding(8)
.background(Color(white: 0.2))
Color.clear.frame(height: 10)
ScrollViewReader { secondScrollProxy in
ScrollView(.horizontal) {
HStack(spacing: 2) {
ForEach(0..<numItems, id:\.self) { x in
Rectangle()
.fill(Color.blue)
.frame(width: itemWidth - 2, height: itemHeight)
.overlay {
Text("\(x)")
}
}
}
.frame(height: itemHeight)
}
.onTapGesture {
withAnimation {
secondScrollProxy.scrollTo(17, anchor: .center)
}
}
}
.padding(8)
.background(Color(white: 0.25))
Spacer()
}
.background(.black)
.foregroundColor(.white)
}
}
ZStack doesn't know about the effect of the position modifier. ZStack just assumes all of its children are piled up on top of each other, since that's how it lays them out. So when the ZStack's parent ScrollView asks the ZStack for its size, the ZStack reports a size that is the maximum width and height of any of its children, without accounting for the side-by-side layout you have manually implemented.
Given Rob's answer, the solution is actually to use the anchor to calculate the the position to scroll to. E.g.
ZStack {
// ...
}
.id("zstack")
.onTapGesture {
.withAnimation {
let x = position_of_17 / width_of_zstack
firstScrollProxy.scrollTo("zstack", anchor: UnitPoint(x: x, y: 1.0))
}
}
}
I found a better workaround, if anyone should have this problem. Simply embed a HStack with a width equal to the ZStack in which you can add invisible elements with the ids and the location that you need to scroll to.
In my case this is acceptable, I only ever need to programmatically scroll to 1 element, and switching to an actual HStack view for my content would not make sense for my use case.
ZStack {
// Content
HStack(spacing: 0) {
Color.clear
.frame(width: targetX, height: 1)
Color.clear
.frame(width: itemWidth, height: 1)
.id("target")
Color.clear
.frame(width: totalWidth - targetX - itemWidth, height: 1)
}
}
func scrollToTarget() {
scrollProxy.scrollTo("target", anchor: anchor)
}
Spending a few days playing around with different modifications and stacks I got stuck in achieve transparent part of the View and text. Goal is made views from code example with .purple color is transparent and they should be background gradient color like in expected result.
Code example:
struct CircleView: View {
var body: some View {
ZStack {
LinearGradient(colors: [.mint, .cyan], startPoint: .topLeading, endPoint: .bottomTrailing)
HStack(spacing: -20) {
ZStack() {
Circle()
.frame(width: 120, height: 120)
.foregroundColor(.white)
Text("🥑")
.font(.system(size: 60))
}
ZStack() {
Circle()
.strokeBorder(.purple, lineWidth: 5)
.background(Circle().fill(.white))
.frame(width: 75, height: 75)
VStack() {
Text("108")
Text("Days")
}
.foregroundColor(.purple)
}
}
}
}
}
Expected result:
I personally would do this with the following extension:
public extension View {
#inlinable
func reverseMask<Mask: View>(
alignment: Alignment = .center,
#ViewBuilder _ mask: () -> Mask
) -> some View {
self.mask {
Rectangle()
.overlay(alignment: alignment) {
mask()
.blendMode(.destinationOut)
}
}
}
}
You can use this on any view to subtract from it. In your example the right circle would look like this:
Circle()
.fill(Color.white)
.frame(width: 75, height: 75)
.reverseMask {
VStack() {
Text("108")
Text("Days")
}
}
Cameron has a great answer and it should be accepted. Based on it here's how you can also make a "transparent" circle around the small circle
struct CircleView: View {
var body: some View {
let bigCircleSize: CGFloat = 120
let smallCircleSize: CGFloat = 75
let borderWidth: CGFloat = 5
let maskCircleSize = smallCircleSize + borderWidth
let spacing: CGFloat = -20
ZStack {
LinearGradient(colors: [.mint, .cyan], startPoint: .topLeading, endPoint: .bottomTrailing)
HStack(spacing: spacing) {
ZStack {
Circle()
.frame(width: bigCircleSize, height: bigCircleSize)
.foregroundColor(.white)
Text("🥑")
.font(.system(size: 60))
}
.reverseMask {
Circle()
.frame(width: maskCircleSize, height: maskCircleSize)
.offset(x: bigCircleSize / 2 + spacing + smallCircleSize / 2)
}
ZStack {
Circle()
.foregroundColor(.white)
.background(Circle().fill(.white))
.frame(width: smallCircleSize, height: smallCircleSize)
.reverseMask {
VStack() {
Text("108")
Text("Days")
}
}
}
}
}
}
}
public extension View {
#inlinable
func reverseMask<Mask: View>(
alignment: Alignment = .center,
#ViewBuilder _ mask: () -> Mask
) -> some View {
self.mask {
Rectangle()
.overlay(alignment: alignment) {
mask()
.blendMode(.destinationOut)
}
}
}
}
The idea is to make another mask on the big circle. Calculate the x-offset and you're good to go
In a giant ScrollView, I want to place Circles, and scroll to them so that, when scrolled, they end up in the middle of the screen, one after another.
I can position the Circles either by using .position, .offset or .padding. I have positioned them (300,300) away from one-another, and so that none of them are on screen when the view is loaded.
When I scroll to the ones positioned with .position or .offset, the ScrollView scrolls to the top left. .offset scrolls with an inset, .position all the way. When I scroll to the one positioned with .padding, it is not centred.
What am I doing wrong here? Why will none of my three attempts scroll so that the circle in question is placed in the middle of the ScrollView?
struct CanvasTester: View {
#State var i = 0
var body: some View {
ZStack(alignment: .topLeading) {
ScrollViewReader { scrollView in
ScrollView([.horizontal, .vertical]) {
ZStack(alignment: .topLeading) {
Spacer()
.frame(width: 4096, height: 4096)
.background(Color.yellow)
.task {
#Sendable func f() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
withAnimation {
scrollView.scrollTo("circle\(i+1)", anchor: .center)
self.i = (self.i + 1) % 3
}
f()
}
}
f()
}
Circle()
.stroke(Color.blue, lineWidth: 4)
.frame(width: 44, height: 44)
.offset(x: 1500, y: 1500)
.id("circle1")
Circle()
.stroke(Color.green, lineWidth: 4)
.frame(width: 44, height: 44)
.position(x: 1800, y: 1800)
.id("circle2")
Circle()
.stroke(Color.red, lineWidth: 4)
.frame(width: 44, height: 44)
.padding([.top, .leading], 2100)
.id("circle3")
}
}
}
Text("#\(i+1)")
}
}
}
Quick run-through the code: I have a ScrollReader encapsulating a ScrollView that scrolls both directions and is 4096x4096. It has a yellow background that on draw launches a function that every second scrolls to the view with either ID "circle1", "circle2" or "circle3". Then follow the three circles with those labels, and finally a label I the top left corner to indicate what color number the ID has.
I need to place a translucent rectangle on front of ScrollView but when i put everything (Rectangle & ScrollView) inside of a ZStack, scroll & touch events stop working within this rectangle.
Atm I'm using .background modifier as it doesn't affect scrolling but I am still looking for way to make it work properly with rectangle placed over (in front of) my ScrollView.
Is there any way to put a View over ScrollView so it wouldn't affect it's functionality?
Here's the code i'm using now (i changed the colors and removed opacity to make the objects visible as my original rectangle is translucent & contains barely visible gradient)
struct ContentView: View {
var body: some View {
ZStack {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(0...100, id:\.self) { val in
ZStack {
Text("test")
.font(.system(size: 128))
} // ZStack
.background(Color.blue)
} // ForEach
}
}
.background(RadialGradient(gradient: Gradient(stops: [
.init(color: Color.blue, location: 0),
.init(color: Color.red, location: 1)]), center: .top, startRadius: 1, endRadius: 200)
.mask(
VStack(spacing: 0) {
Rectangle()
.frame(width: 347, height: 139)
.padding(.top, 0)
Spacer()
}
))
}
}
}
Here is a possible approach to start with - use UIVisualEffectView. And Blur view is taken from How do I pass a View into a struct while getting its height also? topic.
struct ScrollContentView: View {
var body: some View {
ZStack {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(0...100, id:\.self) { val in
ZStack {
Text("test")
.font(.system(size: 128))
} // ZStack
.background(Color.blue)
} // ForEach
}
}
Blur(style: .systemThinMaterialLight)
.mask(
VStack(spacing: 0) {
Rectangle()
.frame(width: 347, height: 139)
.padding(.top, 0)
Spacer()
}
)
.allowsHitTesting(false)
}
}
}
I decided to post a solution here.. it's based on an approach suggested by Asperi.
2Asperi: Thank you, i appreciate your help, as always.
I played a little bit with applying .opacity & mask to Blur but it didn't work.
So i applied mask to the .layer property inside makeUIView and it worked fine
import SwiftUI
struct ContentView: View {
var body: some View {
ZStack {
ZStack {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(0...100, id:\.self) { val in
ZStack {
Text("test")
.font(.system(size: 128))
} // ZStack
.background(Color.white)
} // ForEach
}
}
Blur(style: .systemThinMaterial)
.mask(
VStack(spacing: 0) {
Rectangle()
.frame(width: 347, height: 139)
.padding(.top, 0)
Spacer()
}
)
.allowsHitTesting(false)
}
}
}
}
struct Blur: UIViewRepresentable {
var style: UIBlurEffect.Style = .systemMaterial
func makeUIView(context: Context) -> UIVisualEffectView {
let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: style))
let gradientMaskLayer = CAGradientLayer()
gradientMaskLayer.type = .radial
gradientMaskLayer.frame = CGRect(x: 0, y: 0, width: 347, height: 256)
gradientMaskLayer.colors = [UIColor.black.cgColor, UIColor.clear.cgColor]
gradientMaskLayer.startPoint = CGPoint(x: 0.5, y: 0)
gradientMaskLayer.endPoint = CGPoint(x: 1, y: 1)
gradientMaskLayer.locations = [0 , 0.6]
blurEffectView.layer.mask = gradientMaskLayer
return blurEffectView
}
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
uiView.effect = UIBlurEffect(style: style)
}
}
The only thing i don't understand is why startPoint and endPoint work only when i set them to [0.5,0] & [1,1] but not [0.5,0] & [0.5,1] - i expected that it should determine the direction of radial gradient and in my case it should go from .topCenter to .topBottom (which it does but i don't understand the nature of endPoint)..