0

I am creating an in-app browser and want to support multiple "tabs". As a starting point I have attempted to re-create the same UI/UX that safari has, a "grid" of "cards".

I am trying to figure out how to duplicate the smooth scaling transition when you tap on the "card" in Safari and it scales/grows it to full size.

I can't seem to make a smooth scaling of the card so that the web view's content stays static as it scales from card to full size. Currently the website will keep rendering as the view grows and treats the webView's content as if the view port is being resized.

Here is some of the code:

struct CardGridView: View {
    @Namespace var animation
    @State var selectedIndex: Int?
    @State var viewModels: [CardGridViewModel]

    private let gridItemLayout = [GridItem(.flexible()), GridItem(.flexible())]

    var body: some View {
        NavigationStack {
            ZStack {
                ScrollView {
                    LazyVGrid(columns: gridItemLayout, spacing: 0) {
                        ForEach(Array(viewModels.enumerated()), id: \.offset) { index, viewModel in
                            CardView(title: viewModel.title, color: viewModel.color)
                            .matchedGeometryEffect(id: index, in: animation)
                            .frame(width: 175, height: 280)
                            .padding()
                            .scaleEffect(selectedIndex == nil || selectedIndex == index ? 1 : 0.75) // Other cards will scale down slightly while selected card grows
                            .onTapGesture {
                                withAnimation {
                                    selectedIndex = index
                                }
                            }
                            .overlay { // Card's close button
                                VStack {
                                    HStack(spacing: 0) {
                                        Spacer()
                                        Button {
                                            removeCard(at: index)
                                        } label: {
                                            Image(systemName: "x.circle.fill")
                                                .font(.system(size: 20))
                                                .tint(.primary)
                                        }
                                        .padding(.top, 4)
                                        .padding(.trailing, 4)
                                    }
                                    Spacer()
                                }
                                .padding()
                            }
                        }
                    }
                }
                .onAppear {
                    selectedIndex = 0 // Default to DetailView of first item in array
                }
                .navigationTitle("Card View")
                .navigationBarTitleDisplayMode(.inline)

                if let selectedIndex { // Show DetailView once a card is selected
                    DetailView(title: viewModels[selectedIndex].title, color: viewModels[selectedIndex].color)
                        .matchedGeometryEffect(id: selectedIndex, in: animation)
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                        .ignoresSafeArea()
                }
            }
        }
    }
}

Example of Safari Tabs Animation I'm trying to duplicate:

enter image description here

JimmyJammed
  • 9,598
  • 19
  • 79
  • 146

1 Answers1

0

Your implementation is using .matchedGeometryEffect to perform the zoom effect when a card is selected, but I don't think this is a valid way to use this modifier. It's all a matter of which view is the source for the effect, I was seeing errors in the console.

I couldn't find a way to get .matchedGeometryEffect to work cleanly, so I tried to find an alternative way to implement the zoom transition. I found .scale works ok, if the card position is used as anchor.

For the main issue of the web page re-formatting itself during a transition, I think your best bet is to capture a screenshot when the detail view is minimized. The screenshot can then be used for the card view itself and also during transitions.

So here is an adaption of your code that illustrates the techniques just described, with gaps improvised. I hope it helps:

import SwiftUI
import WebKit

class CardGridViewModel: ObservableObject {
    let title: String
    let color: Color
    @Published var screenshot: UIImage?

    init(title: String, color: Color) {
        self.title = title
        self.color = color
    }
}

struct CloseButton: View {
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            Image(systemName: "x.circle.fill")
                .font(.system(size: 20))
                .tint(.primary)
                .padding(4)
                .background {
                    Circle()
                        .fill(
                            RadialGradient(
                                colors: [
                                    Color(UIColor.systemBackground),
                                    .clear
                                ],
                                center: .center,
                                startRadius: 7,
                                endRadius: 14
                            )
                        )
                }
                .padding()
        }
    }
}

struct CardView: View {
    @ObservedObject var viewModel: CardGridViewModel

    var body: some View {
        if let image = viewModel.screenshot {
            Image(uiImage: image)
                .resizable()
                .scaledToFit()
                .shadow(radius: 3)
        } else {
            ZStack {
                viewModel.color
                    .cornerRadius(6)
                    .shadow(radius: 3)
                Text(viewModel.title)
                    .padding()
            }
        }
    }
}

struct WebView: UIViewRepresentable {
    let urlString: String

    func makeUIView(context: Context) -> WKWebView {
        return WKWebView()
    }

    func updateUIView(_ webView: WKWebView, context: Context) {
        if let url = URL(string: urlString) {
            let request = URLRequest(url: url)
            webView.load(request)
        }
    }
}

struct DetailView: View {
    let viewModel: CardGridViewModel
    let closeAction: () -> Void
    @State private var showingScreenshot = true
    @State private var opacity = 1.0

    @ViewBuilder
    private var screenshot: some View {
        if showingScreenshot {
            if let image = viewModel.screenshot {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFit()
                    .transition(.opacity)
            } else {
                viewModel.color
                    .opacity(opacity)
                    .onAppear {
                        withAnimation(.easeInOut(duration: 1)) {
                            opacity = 0
                        }
                    }
            }
        }
    }

    var body: some View {
        WebView(urlString: viewModel.title)
            .ignoresSafeArea()
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .overlay(alignment: .topTrailing) {
                CloseButton {
                    closeAction()
                    showingScreenshot = true
                    opacity = 1
                }
            }
            .overlay(screenshot)
            .onAppear {
                DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5) {
                    withAnimation {
                        showingScreenshot = false
                    }
                }
            }
    }
}

struct CardGridView: View {
    @State var viewModels: [CardGridViewModel]
    @State var anchor: UnitPoint = .center
    @State var selectedIndex: Int?

    private let cardWidth = CGFloat(175)
    private let paddingSize = CGFloat(10)
    private let gridItemLayout = [GridItem(.flexible()), GridItem(.flexible())]

    private func removeCard(title: String) {
        if let index = viewModels.firstIndex(where: { $0.title == title } ) {
            viewModels.remove(at: index)
        }
    }

    private func cardHeight(geometrySize: CGSize) -> CGFloat {
        cardWidth * (geometrySize.height / geometrySize.width)
    }

    /// - Returns the estimated position of the card with the specified index
    private func cardCenter(index: Int, geometrySize: CGSize) -> UnitPoint {
        let nCols = gridItemLayout.count
        let gridCellWidth = geometrySize.width / CGFloat(nCols)
        let gridCellHeight = cardHeight(geometrySize: geometrySize) + (2 * paddingSize)
        let rowNum = index / nCols
        let x = (CGFloat(index % nCols) * gridCellWidth) + (gridCellWidth / 2)
        let y = (CGFloat(rowNum) * gridCellHeight) + (gridCellHeight / 2)
        let result = UnitPoint(
            x: x / geometrySize.width,
            y: y / geometrySize.height
        )
        return result
    }

    var body: some View {
        NavigationStack {
            GeometryReader { proxy in
                ZStack {
                    ScrollView {
                        LazyVGrid(columns: gridItemLayout, spacing: 0) {
                            ForEach(Array(viewModels.enumerated()), id: \.offset) { index, viewModel in
                                CardView(viewModel: viewModel)
                                    .frame(width: cardWidth, height: cardHeight(geometrySize: proxy.size))
                                    .padding(paddingSize)
                                    .onTapGesture {

                                        // Set the anchor for animation to the estimated position
                                        anchor = cardCenter(index: index, geometrySize: proxy.size)
                                        withAnimation {
                                            selectedIndex = index
                                        }
                                    }
                                    .overlay(alignment: .topTrailing) {
                                        CloseButton {
                                            withAnimation {
                                                removeCard(title: viewModel.title)
                                            }
                                        }
                                    }
                            }
                        }
                    }
                    .scaleEffect(selectedIndex == nil ? 1 : 0.9) // Scale down when a selection is made
                    .onAppear {
                        if !viewModels.isEmpty {

                            // Default to DetailView of first item in array
                            selectedIndex = 0
                        }
                    }
                }
                .navigationTitle("Card View")
                .navigationBarTitleDisplayMode(.inline)

                if let selectedIndex { // Show DetailView once a card is selected
                    DetailView(
                        viewModel: viewModels[selectedIndex],
                        closeAction: {

                            // Capture a screenshot
                            if let scene = UIApplication.shared.connectedScenes.first(
                                where: { $0.activationState == .foregroundActive }
                            ) as? UIWindowScene {
                                viewModels[selectedIndex].screenshot =
                                scene.windows[0].rootViewController?.view.asImage(rect: proxy.frame(in: .global))
                            }
                            // Update the anchor for animation
                            anchor = cardCenter(index: selectedIndex, geometrySize: proxy.size)
                            withAnimation {
                                self.selectedIndex = nil
                            }
                        }
                    )
                    .transition(.scale(scale: 0.1, anchor: anchor))
                }
            }
        }
    }
}

struct ContentView: View {

    private let viewModels: [CardGridViewModel]

    init() {
        let google = CardGridViewModel(title: "https://www.google.com", color: .purple)
        let stackOverflow = CardGridViewModel(title: "https://stackoverflow.com/q/76995839/20386264", color: .blue)
        let bbc = CardGridViewModel(title: "https://bbc.co.uk", color: .green)
        let apple = CardGridViewModel(title: "https://apple.com", color: .indigo)
        self.viewModels = [google, stackOverflow, bbc, apple]
    }

    var body: some View {
        CardGridView(viewModels: viewModels)
    }
}

// Credit to kontiki for the screenshot solution
// https://stackoverflow.com/a/57206207/20386264
extension UIView {
    func asImage(rect: CGRect) -> UIImage {
        let renderer = UIGraphicsImageRenderer(bounds: rect)
        return renderer.image { rendererContext in
            layer.render(in: rendererContext.cgContext)
        }
    }
}
Benzy Neez
  • 1,546
  • 2
  • 3
  • 10