I'm trying to learn SwiftUI and am getting along pretty well, but I've come across an issue with .zIndex / view placement which the tutorials and AI answers can't seem to help me with. Huge thanks to BenzyNeez for his input and I have solved one of my issues of making it go full screen. The sub views should go full screen and be on top of all other views just like the final picture. But the sub views are on top of the full screen view.
struct PeopleView: View {
struct Person: Identifiable {
var id: UUID = UUID()
var first: String
var last: String
var isFullScreen: Bool
var tag: Int
}
func getColor(index: Int) -> Color {
switch index {
case 1: return .blue
case 2: return .green
case 3: return .yellow
case 4: return .orange
case 5: return .purple
case 6: return .pink
default: return .gray
}
}
@Namespace private var animationNamespace
@State private var selectedPerson: Person? = nil
private let personId = UUID()
private let cardWidth: CGFloat = UIScreen.main.bounds.width / 3
@State private var fullscreen: Bool = false
var columns: [GridItem] { Array(repeating: .init(.flexible()), count: 2) }
@State private var people: [Person] = [
Person(first: "John", last: "Doe", isFullScreen: false, tag: 1),
Person(first: "Jane", last: "Doe", isFullScreen: false, tag: 2),
Person(first: "Fred", last: "Doe", isFullScreen: false, tag: 3),
Person(first: "Bill", last: "Doe", isFullScreen: false, tag: 4),
Person(first: "Jack", last: "Doe", isFullScreen: false, tag: 5),
Person(first: "Mary", last: "Doe", isFullScreen: false, tag: 6)
]
private func personView(person: Person) -> some View {
RoundedRectangle(cornerRadius: 5)
.foregroundStyle(getColor(index: person.tag))
.shadow(radius: 5)
.overlay {
Text(person.first)
}
.matchedGeometryEffect(
id: selectedPerson?.id == person.id ? personId : person.id,
in: animationNamespace,
isSource: false
)
}
private var floatingPersonViews: some View {
LazyVGrid(columns: columns, spacing: 20) {
ForEach(people.indices, id: \.self) { index in
personView(person: people[index])
.allowsHitTesting(false)
.frame(height: 320)
}
}
}
private var cardBases: some View {
HStack {
LazyVGrid(columns: columns, spacing: 20) {
ForEach(people.indices, id: \.self) { index in
RoundedRectangle(cornerRadius: 5)
.fill(Color.black)
.frame(width: cardWidth, height: 100)
.onTapGesture {
withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.8, blendDuration: 0.8)) {
selectedPerson = people[index]
people[index].isFullScreen = true
fullscreen = true
}
}
.matchedGeometryEffect(
id: people[index].id,
in: animationNamespace,
isSource: true
)
.zIndex(people[index].isFullScreen ? 1 : -1)
}
}
}
.padding()
}
private var detailBase: some View {
Rectangle()
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
.opacity(0)
.matchedGeometryEffect(
id: personId,
in: animationNamespace,
isSource: true
)
}
private var detailView: some View {
VStack {
detailBase
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.8, blendDuration: 0.8)) {
selectedPerson?.isFullScreen = false
fullscreen = false
selectedPerson = nil
}
}
}
var body: some View {
ZStack {
cardBases//.zIndex(fullscreen ? -3 : 0)
floatingPersonViews//.zIndex(fullscreen ? -2 : 0)
detailView.zIndex(fullscreen ? 1 : -1)
}
}
}
This now works for going full screen and taking up the whole view, however the views in the LazyGrid are still visible on top apart from the last one on the right.
I've tried setting the .zIndex of the DetailView high, and also setting the other two views to low using the fullscreen variable. But it doesn't seem to matter what the .zIndex is set to.
You certainly got quite close to a solution already. With the following changes you can get it working:
The floating person views should be the top layer of the ZStack
and the tap gestures should be attached to these views.
The position of the floating views is determined using .matchedGeometryEffect
, so there is no need for these views to be inside a LazyVGrid
. In fact, this seems to cause problems with zIndex
working. A simple ZStack
can be used as the container for the person views instead.
The placeholders should all have fixed ids for .matchedGeometryEffect
. The ids of the person views are then used to control, which placeholder a person view is matched to (this being either a card in the grid, or the full screen detail view).
There is no need for any boolean flags. The zoom effect can be controlled with just the state variable selectedPerson
. This means the array of persons can be defined using let
, instead of as a state variable (for the purpose of the example anyway).
When a person is selected, its zIndex
needs to be raised, so that it floats above the other cards.
Other suggestions:
The struct Person
is Identifable
, so the ForEach
does not need to use the indices of the array for iteration.
Don't use UIScreen.main
to find the size of the screen. It doesn't work for iPad split screen and it's deprecated anyway. Use a GeometryReader
instead.
Color.clear
can be used as the placeholder for the full screen view. Like all colors, this is greedy, so it uses as much space as possible (the full screen). Add .ignoresSafeArea()
if it should go into the safe area insets too.
There is no need for a return
in every case of the switch statement. You can just use the same switch statement but without the returns.
Here is the updated example:
struct PeopleView: View {
struct Person: Identifiable {
var id: UUID = UUID()
var first: String
var last: String
var tag: Int
}
func getColor(index: Int) -> Color {
switch index {
case 1: .blue
case 2: .green
case 3: .yellow
case 4: .orange
case 5: .purple
case 6: .pink
default: .gray
}
}
@Namespace private var animationNamespace
@State private var selectedPerson: Person? = nil
private let fullScreenId = UUID()
private let columns: [GridItem] = Array(repeating: .init(.flexible()), count: 2)
private let people: [Person] = [
Person(first: "John", last: "Doe", tag: 1),
Person(first: "Jane", last: "Doe", tag: 2),
Person(first: "Fred", last: "Doe", tag: 3),
Person(first: "Bill", last: "Doe", tag: 4),
Person(first: "Jack", last: "Doe", tag: 5),
Person(first: "Mary", last: "Doe", tag: 6)
]
private func personView(person: Person) -> some View {
RoundedRectangle(cornerRadius: 5)
.foregroundStyle(getColor(index: person.tag))
.shadow(radius: 5)
.overlay {
Text(person.first)
}
.zIndex(selectedPerson?.id == person.id ? 1 : 0)
.onTapGesture {
withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.8, blendDuration: 0.8)) {
selectedPerson = selectedPerson == nil ? person : nil
}
}
.matchedGeometryEffect(
id: selectedPerson?.id == person.id ? fullScreenId : person.id,
in: animationNamespace,
isSource: false
)
}
private var floatingPersonViews: some View {
ZStack {
ForEach(people) { person in
personView(person: person)
}
}
}
private var cardBases: some View {
GeometryReader { proxy in
LazyVGrid(columns: columns, spacing: 20) {
ForEach(people) { person in
RoundedRectangle(cornerRadius: 5)
.fill(Color.black)
.frame(width: proxy.size.width / 3, height: 100)
.matchedGeometryEffect(
id: person.id,
in: animationNamespace,
isSource: true
)
}
}
.padding()
}
}
private var detailView: some View {
Color.clear
.matchedGeometryEffect(
id: fullScreenId,
in: animationNamespace,
isSource: true
)
.ignoresSafeArea()
}
var body: some View {
ZStack {
cardBases
detailView
floatingPersonViews
}
}
}
EDIT For tvOS, it seems that gestures work a bit differently. One way to solve is to change the person views into buttons. You can change the way the button is styled by applying a .buttonStyle
.
While we're at it, you may have noticed that the selected view was going behind the other cards when it was going back to its position in the grid. You notice it more if you slow down the animation (on iOS at least, not sure if it is still a problem on tvOS). This is because, the zIndex reverts to 0 before the view has returned to its position.
A way to solve the zIndex issue is to use a second state variable to store the id of the top-most view:
@State private var topPersonId: UUID?
This should be set in the button callback at the same time as selectedPerson
. Actually, it can be set whenever the button is selected, it doesn't have to depend on whether the person was already selected. There is also no need to reset it when the view returns into position, it can just remain the top-most view, even when it is in the grid.
So here is the updated personView
with these changes applied:
private func personView(person: Person) -> some View {
Button {
topPersonId = person.id
withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.8, blendDuration: 0.8)) {
selectedPerson = selectedPerson == nil ? person : nil
}
} label: {
RoundedRectangle(cornerRadius: 5)
.foregroundStyle(getColor(index: person.tag))
.shadow(radius: 5)
.overlay {
Text(person.first)
}
}
.buttonStyle(.plain) // or try .borderless
.zIndex(topPersonId == person.id ? 1 : 0)
.matchedGeometryEffect(
id: selectedPerson?.id == person.id ? fullScreenId : person.id,
in: animationNamespace,
isSource: false
)
}