I am trying to use .matchedGeometryEffect in a scrollview, and using other stack posts I figured out how to do so without seeing any warning messages, such as:
Multiple inserted views in matched geometry group Pair<String, ID>(first: "AB0D062C-C14D-4A2A-8AC4-32CE12AF289FText", second: SwiftUI.Namespace.ID(id: 136)) have isSource: true, results are undefined.
I am wondering if the above message is serious, or is it something that can be lived with. it does mess with the animation but not as much as the issue below.
I used these to figure it out:
and
Why am I encountering "Multiple inserted views in matched geometry group" for a conditional render?
However because of the solutions in the first link, it separates the 2 views, and in a scroll view when the expanded view is dismissed it brings it back to the first cell in the scrollview. This is the issue. I want to keep my animated transition, but also keep the scroll position when I dismiss back to the main view.
It makes the animation not look as clean as it should be, and sometimes it makes mistakes and causes weird things to happen, such as the image. The green square is the 3rd square in the scroll, and when dismissed you can see the red square behind it, which happens to be the 1st square.
I am wondering if there is a solution that works with both of them, I feel like I have to make a choice between the animation or the scroll position.
this is all my code:
struct Home: View {
@State private var colorStructs: [ColorStruct] = [
ColorStruct(color: .red),
ColorStruct(color: .blue),
ColorStruct(color: .green),
ColorStruct(color: .yellow)
]
@Namespace private var resultsNamespace
@State private var isPressed: Bool = false
@State var selectedColor: ColorStruct? = nil
@State private var scrollPosition: String? = nil // Track scroll position
@State private var lastScrollPosition: String? = nil // Save last position
var body: some View {
ZStack {
if !isPressed {
VStack {
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(colorStructs, id: \.id) { color in
RoundedRectangle(cornerRadius: 25)
.fill(color.color.gradient)
.matchedGeometryEffect(id: color.id + "Color", in: resultsNamespace)
.padding(.horizontal, 15)
.containerRelativeFrame(.horizontal)
.overlay {
Text("\(color.id)")
.matchedGeometryEffect(id: color.id + "Text", in: resultsNamespace)
.padding(.horizontal)
}
.onTapGesture {
selectedColor = color
lastScrollPosition = scrollPosition
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
isPressed.toggle()
}
}
.id(color.id)
}
}
.scrollTargetLayout()
}
.scrollPosition(id: $scrollPosition)
.scrollTargetBehavior(.viewAligned)
.scrollIndicators(.hidden)
.frame(height: 250)
.safeAreaPadding(.vertical, 15)
.safeAreaPadding(.horizontal, 25)
}
}
if isPressed {
ExpandedView(
colorStruct: selectedColor ?? ColorStruct(color: .red),
dismiss: $isPressed,
namespace: resultsNamespace,
onDismiss: {
if let lastPos = lastScrollPosition {
scrollPosition = lastPos
}
}
)
}
}
.navigationTitle("Custom Indicator")
}
}
struct ExpandedView: View {
let colorStruct: ColorStruct
@Binding var dismiss: Bool
var namespace: Namespace.ID
var onDismiss: () -> Void
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 25)
.matchedGeometryEffect(id: colorStruct.id + "Color", in: namespace, isSource: false)
.frame(height: 550)
.foregroundStyle(colorStruct.color)
VStack {
Text("\(colorStruct.id)")
.matchedGeometryEffect(id: colorStruct.id + "Text", in: namespace, isSource: false)
.font(.largeTitle)
.fontWeight(.bold)
.offset(y: -150)
.padding(.horizontal)
}
}
.onTapGesture {
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
dismiss.toggle()
onDismiss()
}
}
.padding()
}
}
I have tried other things, such as using zindex() on each view, and changing the isSource in the matchedGeometryEffect, but it either didnt animate or had the same issue as above.
I would appreciate your help.
if there is anything I can help with please ask.
UPDATE:
Desired Behavior: I would like the animation of a square expanding while also keeping the scroll position when I leave the Home view.
Regarding your first point:
I am wondering if the above message is serious, or is it something that can be lived with
The error "Multiple inserted views in matched geometry group" is most certainly serious and should not be ignored. It means that .matchedGeometryGroup
is not being used correctly, because there is more than one item having the same id and flagged as isSource: true
(this being the default).
However, the issue with the mis-placed position has nothing to do with .matchedGeometryEffect
. It still happens if you comment out all the cases of this modifier.
There may be a combination of reasons:
The main reason could be because a lazy container (a LazyHStack
) is being used to hold the items inside the ScrollView
. When an item is selected, the whole ScrollView
is being removed from the view, so the lazy container probably discards its contents. The lazy container is restored when the ScrollView
is shown again (which happens when the overlay is discarded). However, the lazy container probably only loads the first of the child items when first shown. So when the lazy container is initially shown, we briefly see the red item (item 1) before the selection is updated to show the green item (item 3).
Another issue is the way you are trying to restore the scroll position. This needs to be done after the ScrollView
is showing, not before.
Setting the scroll position only actually does anything if there is a change of value.
To fix, the following changes are needed:
In the onTapGesture
callback for showing the expanded view, set scrollPosition
to nil
. This prepares the way for restoring the scroll position later.
Move the code for re-applying the scroll position:
onDismiss
callback.onAppear
callback on the ScrollView
.In ExpandedView
, the two cases of matchedGeometryEffect
should use the default isSource: true
.
To prevent the text from being truncated during the transition, only use .matchedGeometryEffect
to match the .position
of the text.
The updated version below shows how the changes can be applied. It uses the following improvised version of ColorStruct
:
struct ColorStruct {
let color: Color
var id: String {
String(describing: color)
}
}
// Home
ZStack {
if !isPressed {
VStack {
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(colorStructs, id: \.id) { color in
RoundedRectangle(cornerRadius: 25)
.fill(color.color.gradient)
.matchedGeometryEffect(
id: color.id + "Color",
in: resultsNamespace
)
.padding(.horizontal, 15)
.containerRelativeFrame(.horizontal)
.overlay {
Text("\(color.id)")
.matchedGeometryEffect(
id: color.id + "Text",
in: resultsNamespace,
properties: .position // 👈 added
)
.padding(.horizontal)
}
.onTapGesture {
selectedColor = color
lastScrollPosition = scrollPosition
scrollPosition = nil // 👈 added
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
isPressed.toggle()
}
}
.id(color.id)
}
}
.scrollTargetLayout()
}
.scrollPosition(id: $scrollPosition)
.scrollTargetBehavior(.viewAligned)
.scrollIndicators(.hidden)
.frame(height: 250)
.safeAreaPadding(.vertical, 15)
.safeAreaPadding(.horizontal, 25)
.onAppear { // 👈 added
if let lastPos = lastScrollPosition { // 👈 moved from onDismiss
scrollPosition = lastPos
}
}
}
}
if isPressed {
ExpandedView(
colorStruct: selectedColor ?? ColorStruct(color: .red),
dismiss: $isPressed,
namespace: resultsNamespace,
onDismiss: {} // 👈 empty
)
}
}
.navigationTitle("Custom Indicator")
// ExpandedView
ZStack {
RoundedRectangle(cornerRadius: 25)
.matchedGeometryEffect(id: colorStruct.id + "Color", in: namespace) // 👈 use default isSource: true
.frame(height: 550)
.foregroundStyle(colorStruct.color)
VStack {
Text("\(colorStruct.id)")
.matchedGeometryEffect(
id: colorStruct.id + "Text",
in: namespace,
properties: .position // 👈 added, + default isSource: true
)
.font(.largeTitle)
.fontWeight(.bold)
.offset(y: -150)
.padding(.horizontal)
}
}
.onTapGesture {
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
dismiss.toggle()
onDismiss()
}
}
.padding()
An alternative and perhaps simpler way to get it working is to hide the ScrollView
when an item is selected, instead of removing it from the view. The ScrollView
can be hidden but kept in place by changing its opacity to 0. This way, there is no need to track and restore the scroll position at all, because it remains unchanged.
By hiding ExpandedView
in a similar way, the animation can also be improved.
// @State private var scrollPosition: String? = nil // not needed
// @State private var lastScrollPosition: String? = nil // not needed
// Home
ZStack {
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(colorStructs, id: \.id) { color in
RoundedRectangle(cornerRadius: 25)
.fill(color.color.gradient)
.matchedGeometryEffect(
id: color.id + "Color",
in: resultsNamespace,
isSource: !isPressed // 👈 added
)
.padding(.horizontal, 15)
.containerRelativeFrame(.horizontal)
.overlay {
Text("\(color.id)")
.matchedGeometryEffect(
id: color.id + "Text",
in: resultsNamespace,
properties: .position,
isSource: !isPressed // 👈 added
)
.padding(.horizontal)
}
.onTapGesture {
selectedColor = color
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
isPressed.toggle()
}
}
.id(color.id)
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
.scrollIndicators(.hidden)
.frame(height: 250)
.safeAreaPadding(.vertical, 15)
.safeAreaPadding(.horizontal, 25)
.opacity(isPressed ? 0 : 1) // 👈 added
ExpandedView(
colorStruct: selectedColor ?? ColorStruct(color: .red),
dismiss: $isPressed,
namespace: resultsNamespace,
onDismiss: {}
)
.opacity(isPressed ? 1 : 0) // 👈 added
}
.navigationTitle("Custom Indicator")
// ExpandedView
ZStack {
RoundedRectangle(cornerRadius: 25)
.matchedGeometryEffect(
id: colorStruct.id + "Color",
in: namespace,
isSource: dismiss // 👈 added
)
.frame(height: 550)
.foregroundStyle(colorStruct.color)
VStack {
Text("\(colorStruct.id)")
.matchedGeometryEffect(
id: colorStruct.id + "Text",
in: namespace,
properties: .position,
isSource: dismiss // 👈 added
)
.font(.largeTitle)
.fontWeight(.bold)
.offset(y: -150)
.padding(.horizontal)
}
}
.onTapGesture {
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
dismiss.toggle()
onDismiss()
}
}
.padding()
This is how it looks when doing it this way:
For an altogether smoother animation, you could consider using placeholders for the positions, then showing the visible versions as overlays. Examples of where this technique is used can be seen in the answers to the following posts:
SwiftUI .matchGeometryEffect not working smoothly
Photos App-style .matchedGeometryEffect working unexpectedly
MatchGeometryEffect not work properly while return come back to start position