Files
e-party-iOS/yana/MVVM/CommonComponents.swift
edwinQQQ 327d4fd218 feat: 实现动态详情页及相关功能
- 在MePage和MomentListHomePage中新增动态点击事件,支持打开动态详情页。
- 创建MomentDetailPage视图,展示动态详细信息,包括用户信息、动态内容和互动按钮。
- 实现MomentDetailViewModel,管理动态详情页的状态和点赞逻辑。
- 更新MomentListItem组件,添加整体点击回调,提升用户交互体验。
- 优化背景视图组件,确保一致的视觉效果。
2025-09-26 16:49:18 +08:00

430 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: {}
)
}
}