
- 在MePage和MomentListHomePage中新增动态点击事件,支持打开动态详情页。 - 创建MomentDetailPage视图,展示动态详细信息,包括用户信息、动态内容和互动按钮。 - 实现MomentDetailViewModel,管理动态详情页的状态和点赞逻辑。 - 更新MomentListItem组件,添加整体点击回调,提升用户交互体验。 - 优化背景视图组件,确保一致的视觉效果。
430 lines
14 KiB
Swift
430 lines
14 KiB
Swift
import SwiftUI
|
||
|
||
// MARK: - App Image Source Enum
|
||
enum AppImageSource: Equatable {
|
||
case camera
|
||
case photoLibrary
|
||
}
|
||
|
||
// MARK: - 通用底部 Tab 栏组件
|
||
public struct TabBarItem: Identifiable, Equatable {
|
||
public let id: String
|
||
public let title: String
|
||
public let systemIconName: String
|
||
public init(id: String, title: String, systemIconName: String) {
|
||
self.id = id
|
||
self.title = title
|
||
self.systemIconName = systemIconName
|
||
}
|
||
}
|
||
|
||
struct BottomTabBar: View {
|
||
let items: [TabBarItem]
|
||
@Binding var selectedId: String
|
||
let onSelect: (String) -> Void
|
||
var contentPadding: EdgeInsets = EdgeInsets(top: 12, leading: 0, bottom: 12, trailing: 0)
|
||
var horizontalPadding: CGFloat = 0
|
||
|
||
// 便捷初始化:内部固定 tabs,避免外部重复声明
|
||
init(
|
||
selectedId: Binding<String>,
|
||
onSelect: @escaping (String) -> Void,
|
||
contentPadding: EdgeInsets = EdgeInsets(top: 12, leading: 0, bottom: 12, trailing: 0),
|
||
horizontalPadding: CGFloat = 0
|
||
) {
|
||
self.items = BottomTabBar.defaultItems()
|
||
self._selectedId = selectedId
|
||
self.onSelect = onSelect
|
||
self.contentPadding = contentPadding
|
||
self.horizontalPadding = horizontalPadding
|
||
}
|
||
|
||
// 最简初始化:直接接受 viewModel,内部处理所有逻辑
|
||
init(viewModel: MainViewModel) {
|
||
self.items = BottomTabBar.defaultItems()
|
||
self._selectedId = Binding(
|
||
get: { viewModel.selectedTab.rawValue },
|
||
set: { raw in
|
||
if let tab = MainViewModel.Tab(rawValue: raw) {
|
||
viewModel.onTabChanged(tab)
|
||
}
|
||
}
|
||
)
|
||
self.onSelect = { _ in } // 保留但不再使用
|
||
self.contentPadding = EdgeInsets(top: 12, leading: 0, bottom: 12, trailing: 0)
|
||
self.horizontalPadding = 0
|
||
}
|
||
|
||
// 使用 BottomTabView.swift 中的图片资源名进行映射
|
||
private func assetIconName(for item: TabBarItem, isSelected: Bool) -> String? {
|
||
switch item.id {
|
||
case "feed":
|
||
return isSelected ? "feed selected" : "feed unselected"
|
||
case "me":
|
||
return isSelected ? "me selected" : "me unselected"
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// 内部默认 items(与资源映射保持一致)
|
||
private static func defaultItems() -> [TabBarItem] {
|
||
return [
|
||
TabBarItem(id: "feed", title: "Feed", systemIconName: "list.bullet"),
|
||
TabBarItem(id: "me", title: "Me", systemIconName: "person.circle")
|
||
]
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(spacing: 8) {
|
||
ForEach(items) { item in
|
||
Button(action: {
|
||
selectedId = item.id
|
||
onSelect(item.id)
|
||
}) {
|
||
Group {
|
||
if let name = assetIconName(for: item, isSelected: selectedId == item.id) {
|
||
Image(name)
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fit)
|
||
.frame(width: 30, height: 30)
|
||
} else {
|
||
Image(systemName: item.systemIconName)
|
||
.font(.system(size: 24))
|
||
.foregroundColor(selectedId == item.id ? .white : .white.opacity(0.6))
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(contentPadding)
|
||
.contentShape(Rectangle())
|
||
}
|
||
}
|
||
.padding(.horizontal, 8) // 按钮与边缘保持 8 间距
|
||
.padding(.horizontal, horizontalPadding)
|
||
.background(LiquidGlassBackground())
|
||
.clipShape(Capsule())
|
||
.contentShape(Capsule())
|
||
.onTapGesture { /* 吸收空白区域点击,避免穿透 */ }
|
||
.overlay(
|
||
Capsule()
|
||
.stroke(Color.white.opacity(0.12), lineWidth: 0.5)
|
||
)
|
||
.shadow(color: Color.black.opacity(0.2), radius: 12, x: 0, y: 6)
|
||
.safeAreaInset(edge: .bottom) {
|
||
Color.clear.frame(height: 0)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Liquid Glass Background (iOS 26 优先,向下优雅降级)
|
||
struct LiquidGlassBackground: View {
|
||
var body: some View {
|
||
Group {
|
||
if #available(iOS 26.0, *) {
|
||
// iOS 26+:使用系统液态玻璃效果
|
||
Rectangle()
|
||
.fill(Color.clear)
|
||
.glassEffect()
|
||
} else
|
||
if #available(iOS 17.0, *) {
|
||
// iOS 17-25:使用超薄材质 + 轻微高光层
|
||
ZStack {
|
||
Rectangle().fill(.ultraThinMaterial)
|
||
LinearGradient(
|
||
colors: [Color.white.opacity(0.06), Color.clear, Color.white.opacity(0.04)],
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
)
|
||
.blendMode(.softLight)
|
||
}
|
||
} else {
|
||
// 更低版本:半透明备选
|
||
Rectangle()
|
||
.fill(Color.black.opacity(0.2))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 背景视图组件
|
||
struct LoginBackgroundView: View {
|
||
var body: some View {
|
||
Image("bg")
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fill)
|
||
// .ignoresSafeArea(.all)
|
||
}
|
||
}
|
||
|
||
// MARK: - 顶部导航栏组件
|
||
struct LoginHeaderView: View {
|
||
let onBack: () -> Void
|
||
|
||
var body: some View {
|
||
HStack {
|
||
Button(action: onBack) {
|
||
Image(systemName: "chevron.left")
|
||
.font(.system(size: 24, weight: .medium))
|
||
.foregroundColor(.white)
|
||
.frame(width: 44, height: 44)
|
||
}
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 8)
|
||
}
|
||
}
|
||
|
||
// MARK: - 通用输入框组件
|
||
enum InputFieldType {
|
||
case text
|
||
case number
|
||
case password
|
||
case verificationCode
|
||
}
|
||
|
||
struct CustomInputField: View {
|
||
let type: InputFieldType
|
||
let placeholder: String
|
||
let text: Binding<String>
|
||
let isPasswordVisible: Binding<Bool>?
|
||
let onGetCode: (() -> Void)?
|
||
let isCodeButtonEnabled: Bool
|
||
let isCodeLoading: Bool
|
||
let getCodeButtonText: String
|
||
|
||
init(
|
||
type: InputFieldType,
|
||
placeholder: String,
|
||
text: Binding<String>,
|
||
isPasswordVisible: Binding<Bool>? = nil,
|
||
onGetCode: (() -> Void)? = nil,
|
||
isCodeButtonEnabled: Bool = false,
|
||
isCodeLoading: Bool = false,
|
||
getCodeButtonText: String = ""
|
||
) {
|
||
self.type = type
|
||
self.placeholder = placeholder
|
||
self.text = text
|
||
self.isPasswordVisible = isPasswordVisible
|
||
self.onGetCode = onGetCode
|
||
self.isCodeButtonEnabled = isCodeButtonEnabled
|
||
self.isCodeLoading = isCodeLoading
|
||
self.getCodeButtonText = getCodeButtonText
|
||
}
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 25)
|
||
.fill(Color.white.opacity(0.1))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 25)
|
||
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||
)
|
||
.frame(height: 56)
|
||
|
||
HStack {
|
||
// 输入框
|
||
Group {
|
||
switch type {
|
||
case .text, .number:
|
||
TextField("", text: text)
|
||
.placeholder(when: text.wrappedValue.isEmpty) {
|
||
Text(placeholder)
|
||
.foregroundColor(.white.opacity(0.6))
|
||
}
|
||
.keyboardType(type == .number ? .numberPad : .default)
|
||
case .password:
|
||
if let isPasswordVisible = isPasswordVisible {
|
||
if isPasswordVisible.wrappedValue {
|
||
TextField("", text: text)
|
||
.placeholder(when: text.wrappedValue.isEmpty) {
|
||
Text(placeholder)
|
||
.foregroundColor(.white.opacity(0.6))
|
||
}
|
||
} else {
|
||
SecureField("", text: text)
|
||
.placeholder(when: text.wrappedValue.isEmpty) {
|
||
Text(placeholder)
|
||
.foregroundColor(.white.opacity(0.6))
|
||
}
|
||
}
|
||
}
|
||
case .verificationCode:
|
||
TextField("", text: text)
|
||
.placeholder(when: text.wrappedValue.isEmpty) {
|
||
Text(placeholder)
|
||
.foregroundColor(.white.opacity(0.6))
|
||
}
|
||
.keyboardType(.numberPad)
|
||
}
|
||
}
|
||
.foregroundColor(.white)
|
||
.font(.system(size: 16))
|
||
|
||
// 右侧按钮
|
||
if type == .password, let isPasswordVisible = isPasswordVisible {
|
||
Button(action: {
|
||
isPasswordVisible.wrappedValue.toggle()
|
||
}) {
|
||
Image(systemName: isPasswordVisible.wrappedValue ? "eye.slash" : "eye")
|
||
.foregroundColor(.white.opacity(0.7))
|
||
.font(.system(size: 18))
|
||
}
|
||
} else if type == .verificationCode, let onGetCode = onGetCode {
|
||
Button(action: onGetCode) {
|
||
ZStack {
|
||
if isCodeLoading {
|
||
ProgressView()
|
||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||
.scaleEffect(0.7)
|
||
} else {
|
||
Text(getCodeButtonText)
|
||
.font(.system(size: 14, weight: .medium))
|
||
.foregroundColor(.white)
|
||
}
|
||
}
|
||
.frame(width: 60, height: 36)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 15)
|
||
.fill(Color.white.opacity(isCodeButtonEnabled ? 0.2 : 0.1))
|
||
)
|
||
}
|
||
.disabled(!isCodeButtonEnabled || isCodeLoading)
|
||
}
|
||
}
|
||
.padding(.horizontal, 24)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 登录按钮组件
|
||
struct LoginButtonView: View {
|
||
let isLoading: Bool
|
||
let isEnabled: Bool
|
||
let onTap: () -> Void
|
||
|
||
var body: some View {
|
||
Button(action: onTap) {
|
||
Group {
|
||
if isLoading {
|
||
ProgressView()
|
||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||
.scaleEffect(1.2)
|
||
} else {
|
||
Text("Login")
|
||
.font(.system(size: 16, weight: .medium))
|
||
.foregroundColor(.white)
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 16)
|
||
.background(isEnabled ? Color(red: 0.5, green: 0.3, blue: 0.8) : Color.gray)
|
||
.cornerRadius(8)
|
||
.disabled(!isEnabled)
|
||
}
|
||
}
|
||
|
||
// MARK: - 设置行组件
|
||
struct SettingRow: View {
|
||
let title: String
|
||
let subtitle: String
|
||
let action: (() -> Void)?
|
||
|
||
var body: some View {
|
||
Button(action: {
|
||
action?()
|
||
}) {
|
||
HStack(spacing: 16) {
|
||
HStack {
|
||
Text(title)
|
||
.font(.system(size: 16))
|
||
.foregroundColor(.white)
|
||
.multilineTextAlignment(.leading)
|
||
|
||
Spacer()
|
||
|
||
if !subtitle.isEmpty {
|
||
Text(subtitle)
|
||
.font(.system(size: 14))
|
||
.foregroundColor(.white.opacity(0.7))
|
||
}
|
||
}
|
||
|
||
Spacer()
|
||
|
||
if action != nil {
|
||
Image(systemName: "chevron.right")
|
||
.font(.system(size: 14))
|
||
.foregroundColor(.white.opacity(0.5))
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 12)
|
||
}
|
||
.disabled(action == nil)
|
||
}
|
||
}
|
||
|
||
// MARK: - Camera Picker
|
||
struct CameraPicker: UIViewControllerRepresentable {
|
||
let onImagePicked: (UIImage?) -> Void
|
||
|
||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||
let picker = UIImagePickerController()
|
||
picker.sourceType = .camera
|
||
picker.delegate = context.coordinator
|
||
return picker
|
||
}
|
||
|
||
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
||
|
||
func makeCoordinator() -> Coordinator {
|
||
Coordinator(onImagePicked: onImagePicked)
|
||
}
|
||
|
||
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||
let onImagePicked: (UIImage?) -> Void
|
||
|
||
init(onImagePicked: @escaping (UIImage?) -> Void) {
|
||
self.onImagePicked = onImagePicked
|
||
}
|
||
|
||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
|
||
if let image = info[.originalImage] as? UIImage {
|
||
onImagePicked(image)
|
||
} else {
|
||
onImagePicked(nil)
|
||
}
|
||
picker.dismiss(animated: true)
|
||
}
|
||
|
||
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||
onImagePicked(nil)
|
||
picker.dismiss(animated: true)
|
||
}
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
VStack(spacing: 20) {
|
||
LoginBackgroundView()
|
||
|
||
LoginHeaderView(onBack: {})
|
||
|
||
CustomInputField(
|
||
type: .text,
|
||
placeholder: "Test Input",
|
||
text: .constant("")
|
||
)
|
||
|
||
LoginButtonView(
|
||
isLoading: false,
|
||
isEnabled: true,
|
||
onTap: {}
|
||
)
|
||
}
|
||
}
|