如何复制Safari Tabs的动画和过渡?

问题描述 投票:0回答:1

我正在创建一个应用内浏览器,并希望支持多个“选项卡”。作为起点,我尝试重新创建与 safari 相同的 UI/UX,即“卡片”的“网格”。

我正在尝试弄清楚当您在 Safari 中点击“卡片”时如何复制平滑的缩放过渡,并将其缩放/增长到完整尺寸。

我似乎无法对卡片进行平滑缩放,以便网络视图的内容在从卡片缩放到全尺寸时保持静态。目前,网站将随着视图的增长而保持渲染,并将 webView 的内容视为正在调整视口大小。

这是一些代码:

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()
                }
            }
        }
    }
}

我尝试复制的 Safari 选项卡动画示例:

swiftui safari wkwebview swiftui-animation
1个回答
0
投票

您的实现是使用

.matchedGeometryEffect
在选择卡片时执行缩放效果,但我认为这不是使用此修饰符的有效方法。这完全取决于哪个视图是效果的来源,我在控制台中看到了错误。

我找不到一种方法让

.matchedGeometryEffect
干净利落地工作,所以我尝试找到一种替代方法来实现缩放过渡。我发现如果将卡片位置用作锚点,
.scale
可以正常工作。

对于网页在转换过程中重新格式化的主要问题,我认为最好的选择是在最小化详细视图时捕获屏幕截图。然后,屏幕截图可用于卡片视图本身以及转换过程中。

因此,这里是对您的代码的改编,它说明了刚刚描述的技术,并临时弥补了空白。我希望它有帮助:

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)
        }
    }
}
© www.soinside.com 2019 - 2024. All rights reserved.