我正在创建一个应用内浏览器,并希望支持多个“选项卡”。作为起点,我尝试重新创建与 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 选项卡动画示例:
您的实现是使用
.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)
}
}
}