feat: 更新图片预览功能,支持本地与远程图片展示

- 在ImagePreviewPager中引入ImagePreviewSource枚举,支持本地和远程图片的统一处理。
- 优化OptimizedDynamicCardView,新增图片预览状态管理,集成全屏图片预览功能。
- 更新OptimizedImageGrid以支持图片点击事件,触发预览弹窗,提升用户体验。
This commit is contained in:
edwinQQQ
2025-07-22 19:49:42 +08:00
parent ed3e7100c3
commit 8362142c49
2 changed files with 164 additions and 52 deletions

View File

@@ -8,6 +8,11 @@ struct OptimizedDynamicCardView: View {
let allMoments: [MomentsInfo] let allMoments: [MomentsInfo]
let currentIndex: Int let currentIndex: Int
//
@State private var showPreview = false
@State private var previewImageUrls: [String] = []
@State private var previewIndex: Int = 0
init(moment: MomentsInfo, allMoments: [MomentsInfo], currentIndex: Int) { init(moment: MomentsInfo, allMoments: [MomentsInfo], currentIndex: Int) {
self.moment = moment self.moment = moment
self.allMoments = allMoments self.allMoments = allMoments
@@ -17,7 +22,7 @@ struct OptimizedDynamicCardView: View {
public var body: some View { public var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
// //
HStack { HStack(alignment: .top) {
// //
CachedAsyncImage(url: moment.avatar) { image in CachedAsyncImage(url: moment.avatar) { image in
image image
@@ -34,56 +39,48 @@ struct OptimizedDynamicCardView: View {
} }
.frame(width: 40, height: 40) .frame(width: 40, height: 40)
.clipShape(Circle()) .clipShape(Circle())
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(moment.nick) Text(moment.nick)
.font(.system(size: 16, weight: .medium)) .font(.system(size: 16, weight: .medium))
.foregroundColor(.white) .foregroundColor(.white)
Text("ID: \(moment.uid)")
Text(formatTime(moment.publishTime))
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundColor(.white.opacity(0.6)) .foregroundColor(.white.opacity(0.6))
} }
Spacer() Spacer()
// VIP
// VIP Text(formatDisplayTime(moment.publishTime))
if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel { .font(.system(size: 12, weight: .bold))
Text("VIP\(vipLevel)") .foregroundColor(.white.opacity(0.8))
.font(.system(size: 10, weight: .bold)) .padding(.horizontal, 6)
.foregroundColor(.yellow) .padding(.vertical, 2)
.padding(.horizontal, 6) .background(Color.white.opacity(0.15))
.padding(.vertical, 2) .cornerRadius(4)
.background(Color.yellow.opacity(0.2))
.cornerRadius(4)
}
} }
// //
if !moment.content.isEmpty { if !moment.content.isEmpty {
Text(moment.content) Text(moment.content)
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundColor(.white.opacity(0.9)) .foregroundColor(.white.opacity(0.9))
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.padding(.leading, 40 + 8) //
} }
// //
if let images = moment.dynamicResList, !images.isEmpty { if let images = moment.dynamicResList, !images.isEmpty {
OptimizedImageGrid(images: images) OptimizedImageGrid(images: images) { tappedIndex in
previewImageUrls = images.map { $0.resUrl }
previewIndex = tappedIndex
showPreview = true
}
.padding(.bottom, images.count == 2 ? 16 : 0) //
} }
// //
HStack(spacing: 20) { HStack(spacing: 20) {
Button(action: {}) { // Like
HStack(spacing: 4) {
Image(systemName: "message")
.font(.system(size: 16))
Text("\(moment.commentCount)")
.font(.system(size: 14))
}
.foregroundColor(.white.opacity(0.8))
}
Button(action: {}) { Button(action: {}) {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: moment.isLike ? "heart.fill" : "heart") Image(systemName: moment.isLike ? "heart.fill" : "heart")
@@ -93,7 +90,6 @@ struct OptimizedDynamicCardView: View {
} }
.foregroundColor(moment.isLike ? .red : .white.opacity(0.8)) .foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
} }
Spacer() Spacer()
} }
.padding(.top, 8) .padding(.top, 8)
@@ -106,6 +102,13 @@ struct OptimizedDynamicCardView: View {
.onAppear { .onAppear {
preloadNearbyImages() preloadNearbyImages()
} }
//
.fullScreenCover(isPresented: $showPreview) {
ImagePreviewPager(images: previewImageUrls, currentIndex: $previewIndex) {
showPreview = false
previewImageUrls = []
}
}
} }
private func formatTime(_ timestamp: Int) -> String { private func formatTime(_ timestamp: Int) -> String {
@@ -128,6 +131,28 @@ struct OptimizedDynamicCardView: View {
} }
} }
//
private func formatDisplayTime(_ timestamp: Int) -> String {
let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
let now = Date()
let interval = now.timeIntervalSince(date)
let calendar = Calendar.current
if calendar.isDateInToday(date) {
if interval < 60 {
return "刚刚"
} else if interval < 3600 {
return "\(Int(interval / 60))分钟前"
} else {
return "\(Int(interval / 3600))小时前"
}
} else {
formatter.dateFormat = "MM/dd"
return formatter.string(from: date)
}
}
private func preloadNearbyImages() { private func preloadNearbyImages() {
var urlsToPreload: [String] = [] var urlsToPreload: [String] = []
let preloadRange = max(0, currentIndex - 2)...min(allMoments.count - 1, currentIndex + 2) let preloadRange = max(0, currentIndex - 2)...min(allMoments.count - 1, currentIndex + 2)
@@ -140,14 +165,18 @@ struct OptimizedDynamicCardView: View {
} }
ImageCacheManager.shared.preloadImages(urls: urlsToPreload) ImageCacheManager.shared.preloadImages(urls: urlsToPreload)
} }
// UIImageURL
} }
// MARK: - // MARK: -
struct OptimizedImageGrid: View { struct OptimizedImageGrid: View {
let images: [MomentsPicture] let images: [MomentsPicture]
let onImageTap: (Int) -> Void
init(images: [MomentsPicture]) { init(images: [MomentsPicture], onImageTap: @escaping (Int) -> Void) {
self.images = images self.images = images
self.onImageTap = onImageTap
} }
public var body: some View { public var body: some View {
@@ -162,28 +191,38 @@ struct OptimizedImageGrid: View {
let imageSize: CGFloat = min(availableWidth * 0.6, 200) let imageSize: CGFloat = min(availableWidth * 0.6, 200)
HStack { HStack {
Spacer() Spacer()
SquareImageView(image: images[0], size: imageSize) SquareImageView(image: images[0], size: imageSize) {
onImageTap(0)
}
Spacer() Spacer()
} }
case 2: case 2:
let imageSize: CGFloat = (availableWidth - spacing) / 2 let imageSize: CGFloat = (availableWidth - spacing) / 2
HStack(spacing: spacing) { HStack(spacing: spacing) {
SquareImageView(image: images[0], size: imageSize) SquareImageView(image: images[0], size: imageSize) {
SquareImageView(image: images[1], size: imageSize) onImageTap(0)
}
SquareImageView(image: images[1], size: imageSize) {
onImageTap(1)
}
} }
case 3: case 3:
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3 let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
HStack(spacing: spacing) { HStack(spacing: spacing) {
ForEach(images.prefix(3), id: \ .id) { image in ForEach(Array(images.prefix(3).enumerated()), id: \ .element.id) { idx, image in
SquareImageView(image: image, size: imageSize) SquareImageView(image: image, size: imageSize) {
onImageTap(idx)
}
} }
} }
default: default:
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3 let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
let columns = Array(repeating: GridItem(.fixed(imageSize), spacing: spacing), count: 3) let columns = Array(repeating: GridItem(.fixed(imageSize), spacing: spacing), count: 3)
LazyVGrid(columns: columns, spacing: spacing) { LazyVGrid(columns: columns, spacing: spacing) {
ForEach(images.prefix(9), id: \ .id) { image in ForEach(Array(images.prefix(9).enumerated()), id: \ .element.id) { idx, image in
SquareImageView(image: image, size: imageSize) SquareImageView(image: image, size: imageSize) {
onImageTap(idx)
}
} }
} }
} }
@@ -212,14 +251,32 @@ struct OptimizedImageGrid: View {
struct SquareImageView: View { struct SquareImageView: View {
let image: MomentsPicture let image: MomentsPicture
let size: CGFloat let size: CGFloat
let onTap: (() -> Void)?
init(image: MomentsPicture, size: CGFloat) { init(image: MomentsPicture, size: CGFloat, onTap: (() -> Void)? = nil) {
self.image = image self.image = image
self.size = size self.size = size
self.onTap = onTap
} }
public var body: some View { public var body: some View {
let safeSize = size.isFinite && size > 0 ? size : 100 let safeSize = size.isFinite && size > 0 ? size : 100
Group {
if let onTap = onTap {
Button(action: onTap) {
imageContent
}
.buttonStyle(PlainButtonStyle())
} else {
imageContent
}
}
.frame(width: safeSize, height: safeSize)
.clipped()
.cornerRadius(8)
}
private var imageContent: some View {
CachedAsyncImage(url: image.resUrl) { imageView in CachedAsyncImage(url: image.resUrl) { imageView in
imageView imageView
.resizable() .resizable()
@@ -233,8 +290,5 @@ struct SquareImageView: View {
.scaleEffect(0.8) .scaleEffect(0.8)
) )
} }
.frame(width: safeSize, height: safeSize)
.clipped()
.cornerRadius(8)
} }
} }

View File

@@ -1,20 +1,78 @@
import SwiftUI import SwiftUI
enum ImagePreviewSource: Identifiable, Equatable {
case local(UIImage)
case remote(String)
var id: String {
switch self {
case .local(let img):
return String(describing: img.hashValue)
case .remote(let url):
return url
}
}
static func == (lhs: ImagePreviewSource, rhs: ImagePreviewSource) -> Bool {
switch (lhs, rhs) {
case let (.local(l), .local(r)):
return l.pngData() == r.pngData()
case let (.remote(l), .remote(r)):
return l == r
default:
return false
}
}
}
struct ImagePreviewPager: View { struct ImagePreviewPager: View {
let images: [UIImage] let images: [ImagePreviewSource]
@Binding var currentIndex: Int @Binding var currentIndex: Int
let onClose: () -> Void let onClose: () -> Void
//
init(images: [UIImage], currentIndex: Binding<Int>, onClose: @escaping () -> Void) {
self.images = images.map { .local($0) }
self._currentIndex = currentIndex
self.onClose = onClose
}
//
init(images: [String], currentIndex: Binding<Int>, onClose: @escaping () -> Void) {
self.images = images.map { .remote($0) }
self._currentIndex = currentIndex
self.onClose = onClose
}
var body: some View { var body: some View {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
Color.black.ignoresSafeArea() Color.black.ignoresSafeArea()
TabView(selection: $currentIndex) { TabView(selection: $currentIndex) {
ForEach(images.indices, id: \Int.self) { idx in ForEach(Array(images.enumerated()), id: \ .element.id) { idx, source in
Image(uiImage: images[idx]) Group {
.resizable() switch source {
.aspectRatio(contentMode: .fit) case .local(let img):
.frame(maxWidth: .infinity, maxHeight: .infinity) Image(uiImage: img)
.background(Color.black) .resizable()
.tag(idx) .aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
case .remote(let urlStr):
CachedAsyncImage(url: urlStr) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.3))
.overlay(
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
.scaleEffect(0.8)
)
}
}
}
.tag(idx)
} }
} }
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .always)) .tabViewStyle(PageTabViewStyle(indexDisplayMode: .always))