0

I have a horizontal ScrollView, which iterates an array of numbers and for each number a red card is created. When you press a card, that red card expands and only that one is shown. The thing is that when I want to stop expanding the view and go back to the previous state, the whole view is rendered again, returning the scrollview to the beginning (I don't know if I explained myself well, so I'll put an example gif). What I would like to see is keep the position of the element that was selected and not return to the beginning of the ScrollView.

enter image description here

struct TestView: View {
    
    @State private var items = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
    @State private var isAnimationActive = false
    @State private var selectedItem = ""
    @Namespace private var animation
    
    var body: some View {
        ZStack {
            if !isAnimationActive {
                ScrollView(.horizontal, showsIndicators: false) {
                    HStack {
                        ForEach(items, id: \.self) { item in
                            ZStack {
                                RoundedRectangle(cornerRadius: 30, style: .continuous)
                                    .fill(Color.red)
                                    .matchedGeometryEffect(id: "card\(item.description)", in: animation)
                                    .frame(width: 250, height: 250)
                                
                                if selectedItem == item.description && isAnimationActive {
                                    Rectangle()
                                        .fill(.green)
                                } else {
                                    Text(item.description)
                                        .foregroundColor(.white)
                                        .matchedGeometryEffect(id: "text\(item.description)", in: animation)
                                }
                            }
                            .onTapGesture {
                                withAnimation(.easeOut(duration: 0.3)) {
                                    isAnimationActive = true
                                    
                                    selectedItem = item.description
                                }
                            }
                        }
                    }
                    .padding(.leading, 20)
                }
            }
            
            if isAnimationActive {
                ZStack {
                    RoundedRectangle(cornerRadius: 30, style: .continuous)
                        .fill(Color.red)
                        .matchedGeometryEffect(id: "card\(selectedItem.description)", in: animation)
                        .frame(width: 300, height: 400)
                    
                    Text(selectedItem)
                        .foregroundColor(.white)
                        .matchedGeometryEffect(id: "text\(selectedItem.description)", in: animation)
                }
                .transition(.offset(x: 1, y: 1))
                .onTapGesture {
                    withAnimation(.easeOut(duration: 0.3)) {
                        isAnimationActive = false
                    }
                }
            }
        }
    }
}
HangarRash
  • 7,314
  • 5
  • 5
  • 32
j-kobu
  • 3
  • 2
  • Store `selectedItem` somewhere (`@AppStorage`?) and then use a `ScrollViewReader` to scroll to that item in `onAppear` – jnpdx Mar 02 '23 at 17:14

2 Answers2

0

The view gets redrawn each time the value of isAnimationActive changes. To overcome this you could always display the ScrollView and overlay the selected card on top:

struct TestView: View {
    
    @State private var items = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
    @State private var isAnimationActive = false
    @State private var selectedItem = ""
    @Namespace private var animation
    
    var body: some View {
        VStack {
            Spacer()
            ScrollView(.horizontal, showsIndicators: false) {
                HStack {
                    ForEach(items, id: \.self) { item in
                        ZStack {
                            RoundedRectangle(cornerRadius: 30, style: .continuous)
                                .fill(Color.red)
                                .matchedGeometryEffect(id: "card\(item.description)", in: animation)
                                .frame(width: 250, height: 250)
                            Text(item.description)
                                .foregroundColor(.white)
                                .matchedGeometryEffect(id: "text\(item.description)", in: animation)
                        }
                        .onTapGesture {
                            withAnimation(.easeOut(duration: 0.3)) {
                                isAnimationActive = true
                                selectedItem = item.description
                            }
                        }
                    }
                }
                .padding(.leading, 20)
            }
            Spacer()
        }
        .overlay(content: overlay)
    }
    
    @ViewBuilder private func overlay() -> some View{
        if isAnimationActive {
            VStack {
                ZStack {
                    Color(uiColor: .systemBackground)
                    RoundedRectangle(cornerRadius: 30, style: .continuous)
                        .fill(Color.red)
                        .matchedGeometryEffect(id: "card\(selectedItem.description)", in: animation)
                        .frame(width: 300, height: 400)
                    
                    Text(selectedItem)
                        .foregroundColor(.white)
                        .matchedGeometryEffect(id: "text\(selectedItem.description)", in: animation)
                }
                .transition(.offset(x: 1, y: 1))
                .onTapGesture {
                    withAnimation(.easeOut(duration: 0.3)) {
                        isAnimationActive = false
                    }
                }
            }
        }
    }
}
LuLuGaGa
  • 13,089
  • 6
  • 49
  • 57
  • [“If the number of currently-inserted views in the group with `isSource = true` is not exactly one results are undefined, due to it not being clear which is the source view.”](https://developer.apple.com/documentation/swiftui/view/matchedgeometryeffect(id:in:properties:anchor:issource:)) You need to set `isSource: false` on the selected item in the `ScrollView` to avoid undefined behavior. – rob mayoff Mar 02 '23 at 18:02
0

Instead of removing the ScrollView from the view hierarchy, use the opacity modifier to hide the ScrollView when there is a selection.

Keeping the ScrollView always present means you have to use set the isSource of the selected item's matchedGeometryEffect to false in the ScrollView. Otherwise, SwiftUI can get confused.

Result:

A horizontal scroll view containing red cards. Each card has a number on it, starting with zero. I scroll the cards so the 3 card is visible and tap it. The 3 card gets larger and the other cards fade away. I tap again and the 3 card gets smaller and the other cards reappear. I repeat the process for the 4 card.

Also, let's clean up your data model. It's not clear why you have separate properties selectedItem and isAnimationActive. Unless there's a good reason, it's better to have one optional property.

Code:

typealias CardId = Int

struct ContentView: View {
    @State private var cards: [CardId] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
    @State private var selection: CardId? = nil
    @Namespace private var namespace

    var body: some View {
        ZStack {
            ScrollView(.horizontal, showsIndicators: false) {
                HStack {
                    ForEach(cards, id: \.self) { id in
                        cardView(
                            for: id,
                            isSource: selection != id
                        )
                        .frame(width: 250, height: 250)
                        .onTapGesture {
                            withAnimation(.easeOut(duration: 0.3)) {
                                selection = id
                            }
                        }
                    }
                }
                .padding(.leading, 20)
            }
            .opacity(selection == nil ? 1 : 0)

            if let selection {
                ZStack {
                    cardView(
                        for: selection,
                        isSource: true
                    )
                    .frame(width: 300, height: 400)
                    .transition(.offset(x: 1, y: 1))
                    .onTapGesture {
                        withAnimation(.easeOut(duration: 0.3)) {
                            self.selection = nil
                        }
                    }
                }
            }
        }
    }

    func cardView(for id: CardId, isSource: Bool) -> some View {
        RoundedRectangle(cornerRadius: 30, style: .continuous)
            .fill(Color.red)
            .shadow(radius: 2, y: 1)
            .overlay {
                Text("\(id)")
                    .foregroundColor(.white)
                    .font(.largeTitle)
            }
            .matchedGeometryEffect(id: id, in: namespace,isSource: isSource)
    }
}
rob mayoff
  • 375,296
  • 67
  • 796
  • 848
  • Thank you very much for your answer, it helped me a lot. And analyzing your code, I learned quite a few things. – j-kobu Mar 02 '23 at 20:33