Why am I encountering "Multiple inserted views in matched geometry group" for a conditional render?

68 views Asked by At

I have two views and am trying to Matched Geometry animate from one view where the geometry is deep to an overlay view. However, I keep getting an error in the console, claiming there are two geometries with the same id and isSource: true. I'm purposely not setting source because I want the animation to work forward and backward. Code is below and attempts are further down. (See code comments where the conditional rendering occurs)

Omitted a bunch of code because the view is fairly complex. The error is for all matched geometries in the view, here are two examples. Optional Matched Geometry is a helper that maps to matchedGeometryEffect if id and ns are both defined, otherwise returns the view.

Parent View:

struct ExploreView: View {
  let ns: Namespace.ID
  var navPath: Binding<ExploreNavigationPath>

  @StateObject var sliderModel = ExplorePackageModel()
  
  var body: some View {
    GeometryReader { geom in
      ZStack {
        VStack(...) {
          ...
          PackageSlider(ns: ns) { pkg in
            withAnimation(.spring) {
              sliderModel.displayPkgDetails = pkg
            }
          }
        }
         ...
        .frame(height: geom.size.height)

        // CONDITIONAL RENDER FOR ANIMATING TO
        if let pkg = sliderModel.displayPkgDetails {
          PackageView(
            ns: ns,
            optimisticData: pkg
          ) {
            withAnimation(.spring) {
              sliderModel.displayPkgDetails = nil
            }
          }
          .zIndex(100)
        }
      }
    }
    .ignoresSafeArea(.keyboard, edges: [.top])
    .environmentObject(sliderModel)
  }
}

Child Default View, Drawn Underneath Overlay (animating from)

struct PackageSlider: View {
  var ns: Namespace.ID? = nil
  var onSlidePress: ((MPackageSlide) -> Void)? = nil

  @EnvironmentObject var model: ExplorePackageModel

  var body: some View {
    GeometryReader { proxy in
      ScrollView(...) {
        LazyVStack(
          spacing: proxy.safeAreaInsets.bottom / 2
        ) {
          let loadingSlide = PackageSlide()
            .frame(height: proxy.size.height + proxy.safeAreaInsets.top)

          switch self.model.state {
          case .Start, .Loading:
            loadingSlide // correctly prints onAppear, onDisappear

          case .LoadingNewData(oldData: let data), .Success(data: let data):

            ForEach(data) { pkg in
              Group { // Group was an attempt that doesn't seem to affect anything
                // CONDITIONAL RENDER FOR ANIMATING FROM
                if model.displayPkgDetails?.id == pkg.id {
                  // A print here DOES occur on transition
                  loadingSlide
                    .zIndex(0)
                    .id(pkg.id)
                } else {
                  PackageSlide(
                    ns: ns,
                    onSlidePress: onSlidePress,
                    pkg: pkg
                  )
                  .frame(height: proxy.size.height + proxy.safeAreaInsets.top)
                  .id(pkg.id)
                }
              }
            }

            if case .LoadingNewData(oldData: _) = self.model.state {
              loadingSlide
            }

          case .Error(error: _):
             ...
          }
        }
        .scrollTargetLayout()
      }
      .scrollTargetBehavior(.paging)
      ...
    }
  }
}

struct PackageSlide: View {
  var ns: Namespace.ID? = nil
  ...

  var pkg: MPackageSlide? = nil

  var id: String? { pkg?.id }
  ... // other properties unwrapped similarly

  var nsType: PackageNS { PackageNS(id) }

  func safePressHandler() {
    if let pkg = pkg {
      onSlidePress?(pkg)
    }
  }

  var body: some View {
    VStack(alignment: .center, spacing: 0) {
      ...

      // Example matched geometry, others omitted for concision
      PackageFooter(prices: prices) {
        safePressHandler()
      }
      .optionalMatchedGeometry(id: nsType.bookFooter, in: ns)
    }
    .onAppear {
      print("pkg view \(String(describing: id)) loading")
    }
    .onDisappear {
      print("pkg view \(String(describing: id)) unloading")
    }
  }
}

Child Overlay Snippet (animating to)

 PackageDescription(description: optimisticData?.description)
        .frame(maxWidth: .infinity)
        .optionalMatchedGeometry(id: nsType.packageDesc, in: ns)

      PackageProperties(properties: optimisticData?.properties)
        .optionalMatchedGeometry(id: nsType.packageProps, in: ns)
    }

Attempts and Suspicions

Weirdly enough, there doesn't seem to be an unloading of the target view when transitioning. onDisappear is not printed at all when I trigger the transition. Perhaps this is something to do with the ForEach? onDisappear does trigger for the other loadingView usages. Interestingly, putting a print in the ForEach render away branch does seem trigger. I suspect the new branch is running but the old branch's view is not unloading.

I'm also hesitant to remove the package with the id from the model because the model manages loading the data; concurrency issues can make a mess if I simply store and reload the package with the id.

1

There are 1 answers

0
Jack On

Today I learned a valuable lesson on id's in SwiftUI.

Because the id was not changed, SwiftUI did not see the need to rerender the two branches in the ForEach.