3

I am basically trying to recreate the photos app. In doing so, matched geometry effect should be the best way to recreate the animation that is used in the photos app when you click on an image/close it. However, on opening of an image it only does half of the animation. When closing the image the animation is only contained to the lazyvgrid individual image not the whole view. Also the first image of the gallery simply does not animate when closing.

Gallery view is made from a lazyvgrid and for each, full screen view is made of a tabview and for each.

Here is what it looks like:

Example

Main view:

struct ImageSelectorView: View {
    @EnvironmentObject var isvm: ImageSelectorViewModel
    @Namespace var namespace
    @State private var selectedImages: [SelectedImagesModel] = []
    @State private var selectedImageID: String = ""
    @State private var liveEventID: String = ""
    @State var showImageFSV: Bool = false
    @State var showPicker: Bool = false
    @Binding var liveEvent: [EventModel]
    public var pickerConfig: PHPickerConfiguration {
        var config = PHPickerConfiguration(photoLibrary: .shared())
        config.filter = .any(of: [.images, .livePhotos, .videos])
        config.selectionLimit = 10
        return config
    }
    private var gridItemLayout = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
    private let viewWidth: CGFloat = UIScreen.main.bounds.width
    private let viewHeight: CGFloat = UIScreen.main.bounds.height
    private let viewHPadding: CGFloat = 30
    
    init(liveEvent: Binding<[EventModel]>) {
        self._liveEvent = liveEvent
    }
    
    var body: some View {
        ZStack {
            Color.theme.background.ignoresSafeArea()
            VStack {
                ZStack {
                    ScrollView(.vertical, showsIndicators: true) {
                        LazyVGrid(columns: self.gridItemLayout, alignment: .center, spacing: 0.5) {
                            ForEach(self.liveEvent[0].eventImages.indices) { image in
                                GalleryImage(selectedImageID: self.$selectedImageID, showImageFSV: self.$showImageFSV, image: self.liveEvent[0].eventImages[image], namespace: self.namespace)
                            }
                        }
                    }
                    
                    if self.showImageFSV {
                        KFImagesFSV(eventImages: self.$liveEvent[0].eventImages, showImageFSV: self.$showImageFSV, selectedImageID: self.$selectedImageID, namespace: self.namespace)
                    }
                }
            }
        }
    }
}

Gallery Image View:

struct GalleryImage: View {
    @Binding var selectedImageID: String
    @Binding var showImageFSV: Bool
    public var image: EventImage
    public var namespace: Namespace.ID
    private let viewWidth: CGFloat = UIScreen.main.bounds.width
    private let viewHeight: CGFloat = UIScreen.main.bounds.height
    var body: some View {
        Button {
            DispatchQueue.main.async {
                withAnimation(.spring()) {
                    self.selectedImageID = image.id
                    if self.selectedImageID == image.id {
                        self.showImageFSV.toggle()
                    }
                }
            }
        } label: {
            KFImage(URL(string: image.url))
                .placeholder({
                    Image("topo")
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                })
                .loadDiskFileSynchronously()
                .cacheMemoryOnly()
                .fade(duration: 0.2)
                .resizable()
                .matchedGeometryEffect(id: self.selectedImageID == image.id ? "" : image.id, in: self.namespace)
                .aspectRatio(contentMode: .fill)
                .frame(width: (self.viewWidth/2.9) - 3, height: (self.viewWidth/2.9) - 3)
                .clipped()
        }
    }
}

Image full screen view (tab view):

struct KFImagesFSV: View {
    @Binding var eventImages: [EventImage]
    @Binding var showImageFSV: Bool
    @Binding var selectedImageID: String
    public var namespace: Namespace.ID
    private let viewWidth: CGFloat = UIScreen.main.bounds.width
    private let viewHeight: CGFloat = UIScreen.main.bounds.height
    private let viewHPadding: CGFloat = 30
    var body: some View {
        ZStack {
            TabView(selection: self.$selectedImageID) {
                ForEach(self.eventImages.indices) { image in
                    KFImage(URL(string: self.eventImages[image].url))
                        .placeholder({
                            Image("topo")
                                .resizable()
                                .aspectRatio(contentMode: .fill)
                        })
                        .loadDiskFileSynchronously()
                        .cacheMemoryOnly()
                        .fade(duration: 0.2)
                        .resizable()
                        .tag(self.eventImages[image].id)
                        .matchedGeometryEffect(id: self.selectedImageID == self.eventImages[image].id ? self.eventImages[image].id : "", in: self.namespace)
                        .aspectRatio(contentMode: .fit)
                        .frame(width: self.viewWidth, height: self.viewHeight)
                }
            }
            .tabViewStyle(PageTabViewStyle(indexDisplayMode: .always))
        }
    }
}
Trevor
  • 580
  • 5
  • 16
  • 2
    [This tutorial from SwiftUI Lab](https://swiftui-lab.com/matchedgeometryeffect-part1/) is the best I know of on the subject. – Yrb Feb 12 '22 at 01:33
  • @Yrb ive been referencing that, still having a difficult time when each 'object' is inside a for each. – Trevor Feb 12 '22 at 03:29
  • I think its the TabVIew ... I'm on it ... – ChrisR Feb 12 '22 at 14:45
  • The reason I pointed you there is there does not seem to be any `.animation` in your `KFImagesFSV`. You have to have it animated both ways. That, I am sure is the reason when you are going back to the grid view, that the animation only runs in the grid view. As to the rest of it, you don't seem to have set up the configuration from the tutorial that handles the transition from the grid view to the hero view. Did you download the linked project? The tutorial kind of ends with that project for you to figure out the rest yourself. – Yrb Feb 12 '22 at 15:10
  • @ChrisR good luck, im on day 4 of trying to get this to work like the photos app. I have the animations down, but i cant get the foreach of lazyvgrid to match the for each in tabview. they just wont sync up to the same index image. – Trevor Feb 13 '22 at 03:05
  • @Yrb I have the animations figured out and it works when the FSV image is just a single image. The problem is getting the selected image from the gallery to match the FSV tabview of images. – Trevor Feb 13 '22 at 03:07

3 Answers3

3

After following ChrisR's answer, I decided I wanted to add a gesture to allow swiping to dismiss. This is not possible with TabView, so I remade the whole thing with a LazyHstack instead. I will not be marking this as the correct answer since the original question does not include a lazyhstack. This is simply another approach to the original problem.

Link to simplified code:

github repo

example

Gallery View:

struct EventGalleryView: View {
    @Namespace var namespace
    @GestureState private var selectedImageOffset: CGSize = .zero
    @Binding var eventImages: [EventImage]
    @State private var selectedImageIndex: Int? = nil
    @State private var selectedImageScale: CGFloat = 1
    @State private var showFSV: Bool = false
    @State private var isSwiping: Bool = false
    @State private var isSelecting: Bool = false
    private var gridItemLayout = Array(repeating: GridItem(.flexible()), count: 3)
    init(eventImages: Binding<[EventImage]>) {
        self._eventImages = eventImages
    }
    var body: some View {
        GeometryReader { geo in
            let geoWidth = geo.size.width
            let geoHeight = geo.size.height
            ScrollView(.vertical, showsIndicators: true) {
                LazyVGrid(columns: self.gridItemLayout, alignment: .center, spacing: 0.5) {
                    ForEach(eventImages) { image in
                        GalleryImageView(image: image)
                            .matchedGeometryEffect(id: eventImages.firstIndex(of: image), in: self.namespace, isSource: self.showFSV ? false : true)
                            .aspectRatio(contentMode: .fill)
                            .frame(width: (geoWidth/2.9) - 3, height: (geoWidth/2.9) - 3, alignment: .center)
                            .clipped()
                            .contentShape(Rectangle())
                            .opacity(eventImages.firstIndex(of: image) == selectedImageIndex ? 0 : 1)
                            .onTapGesture {
                                DispatchQueue.main.async {
                                    withAnimation(.spring()) {
                                        self.showFSV = true
                                        self.selectedImageIndex = eventImages.firstIndex(of: image)
                                    }
                                }
                            }
                    }
                }
            }
            .zIndex(0)
            
            ImageFSV(selectedImageOffset: self.selectedImageOffset, showFSV: self.$showFSV, selectedImageIndex: self.$selectedImageIndex, selectedImageScale: self.$selectedImageScale, isSelecting: self.$isSelecting, isSwiping: self.$isSwiping, eventImages: self.eventImages, geoWidth: geoWidth, geoHeight: geoHeight, namespace: self.namespace)
        }
    }
}

FSV image view:

struct ImageFSV: View {
    @GestureState var selectedImageOffset: CGSize
    @State private var backgroundOpacity: CGFloat = 1
    @Binding var showFSV: Bool
    @Binding var selectedImageIndex: Int?
    @Binding var selectedImageScale: CGFloat
    @Binding var isSelecting: Bool
    @Binding var isSwiping: Bool
    public var eventImages: [EventImage]
    public let geoWidth: CGFloat
    public let geoHeight: CGFloat
    public let namespace: Namespace.ID
    var body: some View {
        if self.showFSV, let index = self.selectedImageIndex {
            Color.theme.background.ignoresSafeArea()
                .opacity(self.backgroundOpacity)
                .zIndex(1)
            LazyHStack(spacing: 0) {
                ForEach(eventImages) { image in
                    GalleryImageView(image: image)
                        .if(self.eventImages.firstIndex(of: image) == self.selectedImageIndex && self.isSelecting, transform: { view in
                            view
                                .matchedGeometryEffect(id: self.selectedImageIndex, in: self.namespace, isSource: true)
                        })
                            .aspectRatio(contentMode: .fit)
                            .frame(width: geoWidth, height: geoHeight, alignment: .center)
                            .scaleEffect(eventImages.firstIndex(of: image) == self.selectedImageIndex ? self.selectedImageScale : 1)
                            .offset(x: -CGFloat(index) * geoWidth)
                            .offset(eventImages.firstIndex(of: image) == self.selectedImageIndex ? self.selectedImageOffset : .zero)
                }
            }
            .animation(.easeOut(duration: 0.25), value: index)
            .highPriorityGesture(
                DragGesture()
                    .onChanged({ value in
                        DispatchQueue.main.async {
                            if !self.isSelecting && (value.translation.width > 5 || value.translation.width < -5) {
                                self.isSwiping = true
                            }
                            if !self.isSwiping && (value.translation.height > 5 || value.translation.height < -5) {
                                self.isSelecting = true
                            }
                        }
                    })
                    .updating(self.$selectedImageOffset, body: { value, state, _ in
                        if self.isSwiping {
                            state = CGSize(width: value.translation.width, height: 0)
                        } else if self.isSelecting {
                            state = CGSize(width: value.translation.width, height: value.translation.height)
                        }
                    })
                    .onEnded({ value in
                        DispatchQueue.main.async {
                            self.isSwiping = false
                            if value.translation.height > 150 && self.isSelecting {
                                withAnimation(.spring()) {
                                    self.showFSV = false
                                    self.selectedImageIndex = nil
                                    self.isSelecting = false
                                }
                            } else {
                                self.isSelecting = false
                                let offset = value.translation.width / geoWidth*6
                                if offset > 0.5 && self.selectedImageIndex ?? 0 > 0 {
                                    self.selectedImageIndex! -= 1
                                } else if offset < -0.5 && self.selectedImageIndex ?? 0 < (eventImages.count - 1) {
                                    self.selectedImageIndex! += 1
                                }
                            }
                        }
                    })
            )
            .onChange(of: self.selectedImageOffset) { imageOffset in
                DispatchQueue.main.async {
                    withAnimation(.easeIn) {
                        switch imageOffset.height {
                            case 50..<70:
                                self.backgroundOpacity = 0.8
                            case 70..<90:
                                self.backgroundOpacity = 0.6
                            case 90..<110:
                                self.backgroundOpacity = 0.4
                            case 110..<130:
                                self.backgroundOpacity = 0.2
                            case 130..<1000:
                                self.backgroundOpacity = 0.0
                            default:
                                self.backgroundOpacity = 1.0
                        }
                    }
                    
                    let progress = imageOffset.height / geoHeight
                    if 1 - progress > 0.5 {
                        self.selectedImageScale = 1 - progress
                    }
                }
            }
            .zIndex(2)
        }
    }
}

Gallery image view:

struct GalleryImageView: View {
    public let image: EventImage
    var body: some View {
        KFImage(URL(string: image.url))
            .placeholder({
                Image("topo")
                    .resizable()
                    .aspectRatio(contentMode: .fill)
            })
            .loadDiskFileSynchronously()
            .cacheMemoryOnly()
            .fade(duration: 0.2)
            .resizable()
    }
}

EventImage model:

struct EventImage: Identifiable, Codable, Hashable {
    var id = UUID().uuidString
    var url: String
    var voteCount: Int
    
    private enum eventImage: String, CodingKey {
        case id
        case imageURL
        case voteCount
    }
}
Trevor
  • 580
  • 5
  • 16
2

This is how far I got. The zoom out from FullScreenView to GalleryView works. the only thing that doesn't, is a clean zoom in into the TabView. I suppose this is because of the wrapping by TabView.

enter image description here

struct ImageStruct: Identifiable {
    let id = UUID()
    var image: String = ""
}

let imagesArray = [
    ImageStruct(image: "image1"),
    ImageStruct(image: "image2"),
    ImageStruct(image: "image3"),
    ImageStruct(image: "image4"),
    ImageStruct(image: "image5"),
    ImageStruct(image: "image6"),
    ImageStruct(image: "image7"),
    ImageStruct(image: "image8")
]



struct ContentView: View {
    
    @Namespace var ns
    @State private var selectedImage: UUID?
    
    var body: some View {
//        ZStack {
            if selectedImage == nil {
                GalleryView(selectedImage: $selectedImage, ns: ns)
            } else {
                FullScreenView(selectedImage: $selectedImage, ns: ns)
            }
//        }
    }
}


struct GalleryView: View {
    
    @Binding var selectedImage: UUID?
    var ns: Namespace.ID
    
    private let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
    
    var body: some View {
        VStack {
            ScrollView(.vertical, showsIndicators: true) {
                
                LazyVGrid(columns: columns) {
                    
                    ForEach(imagesArray) { image in
                        
                        Color.clear.overlay(
                            Image(image.image)
                                .resizable()
                                .aspectRatio(contentMode: .fill)
                                .matchedGeometryEffect(id: image.id, in: ns, isSource: true)
                        )
                            .clipped()
                            .aspectRatio(1, contentMode: .fit)
                        
                            .onTapGesture {
                                withAnimation {
                                    selectedImage = image.id
                                }
                            }
                    }
                }
            }
        }
    }
}


struct FullScreenView: View {
    
    @Binding var selectedImage: UUID?
    var ns: Namespace.ID
    
    init(selectedImage: Binding<UUID?>, ns: Namespace.ID) {
        print(selectedImage)
        self._selectedImage = selectedImage
        self.ns = ns
        // initialize selctedTab to selectedImage
        self._selectedTab = State(initialValue: selectedImage.wrappedValue ?? UUID())
    }
    
    
    @State private var selectedTab: UUID
    
    var body: some View {
        
        TabView(selection: $selectedTab) {
            
            ForEach(imagesArray) { image in
                
                Image(image.image)
                    .resizable()
                    .scaledToFit()
                    // ternary applying effect only for selected tab
                    .matchedGeometryEffect(id: image.id == selectedTab ? selectedTab : UUID(),
                                           in: ns, isSource: true)
                
                    .tag(image.id)
                
                    .onTapGesture {
                        withAnimation {
                            selectedImage = nil
                        }
                    }
            }
        }
        .tabViewStyle(.page)
    }
}
ChrisR
  • 9,523
  • 1
  • 8
  • 26
  • This looks great, check out my answer below if you want to be able to swipe to dismiss the FSV image. – Trevor Feb 15 '22 at 04:06
0

I am building a similar gallery and I was able to make it work. You need to have LazyVGrid and TabView in the same View.

Then apply matchedGeometryEffect like this:

HStack {
    TabView(...) {
        ...
    }
}.matchedGeometryEffect(...)

EDIT: Just tested - you can have them in separate Views, but you need to apply matchedGeometryEffect on the view with the TabView, not inside.

Neurythmic
  • 25
  • 5
  • do you have a demo link? With my current 'solution' Im able to replicate the animation with about 95% accuracy, but it throws warnings. – Trevor Mar 08 '22 at 01:33