feat: 更新Info.plist和AppSettingView以支持相机和图片选择功能
- 在Info.plist中新增相机使用说明,确保应用能够访问相机功能。 - 在AppSettingFeature中新增showImagePicker状态和setShowImagePicker动作,支持图片选择弹窗的显示。 - 在AppSettingView中整合图片选择与预览功能,优化用户体验。 - 更新MainView以简化导航逻辑,提升代码可读性与维护性。 - 在ImagePickerWithPreview组件中实现相机和相册选择功能,增强交互性。
This commit is contained in:
@@ -29,6 +29,8 @@ struct AppSettingFeature {
|
||||
self.avatarURL = avatarURL
|
||||
self.userInfo = userInfo
|
||||
}
|
||||
// 新增:TCA驱动图片选择弹窗
|
||||
var showImagePicker: Bool = false
|
||||
}
|
||||
|
||||
enum Action: Equatable {
|
||||
@@ -61,6 +63,8 @@ struct AppSettingFeature {
|
||||
case nicknameInputChanged(String)
|
||||
case nicknameEditAlert(Bool)
|
||||
case testPushTapped
|
||||
// 新增:TCA驱动图片选择弹窗
|
||||
case setShowImagePicker(Bool)
|
||||
}
|
||||
|
||||
@Dependency(\.apiService) var apiService
|
||||
@@ -231,6 +235,9 @@ struct AppSettingFeature {
|
||||
return .none
|
||||
case .testPushTapped:
|
||||
return .none
|
||||
case .setShowImagePicker(let show):
|
||||
state.showImagePicker = show
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -9,6 +9,8 @@
|
||||
</dict>
|
||||
<key>NSWiFiUsageDescription</key>
|
||||
<string>应用需要访问 Wi-Fi 信息以提供网络相关功能</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>需要使用相机拍照上传图片</string>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>Bayon-Regular.ttf</string>
|
||||
|
@@ -16,41 +16,106 @@ struct AppSettingView: View {
|
||||
initialState: ImagePickerWithPreviewReducer.State(inner: ImagePickerWithPreviewState(selectionMode: .single)),
|
||||
reducer: { ImagePickerWithPreviewReducer() }
|
||||
)
|
||||
@State private var showImagePicker = false
|
||||
@State private var showNicknameAlert = false
|
||||
@State private var nicknameInput = ""
|
||||
@State private var showImagePickerSheet = false
|
||||
@State private var showActionSheet = false
|
||||
@State private var showPhotoPicker = false
|
||||
@State private var showCamera = false
|
||||
@State private var selectedPhotoItems: [PhotosPickerItem] = []
|
||||
@State private var selectedImages: [UIImage] = []
|
||||
@State private var cameraImage: UIImage? = nil
|
||||
@State private var previewIndex: Int = 0
|
||||
@State private var showPreview = false
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String? = nil
|
||||
|
||||
var body: some View {
|
||||
WithViewStore(store, observe: { $0 }) { viewStore in
|
||||
ZStack {
|
||||
mainContent(viewStore: viewStore)
|
||||
if showImagePicker {
|
||||
WithPerceptionTracking{
|
||||
ImagePickerWithPreviewView(store: pickerStore) { images in
|
||||
if let image = images.first, let data = image.jpegData(compressionQuality: 0.8) {
|
||||
viewStore.send(.avatarSelected(data))
|
||||
}
|
||||
showImagePicker = false
|
||||
WithPerceptionTracking {
|
||||
ZStack {
|
||||
mainContent(viewStore: viewStore)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"请选择图片来源",
|
||||
isPresented: $showActionSheet,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("拍照") { showCamera = true }
|
||||
Button("从相册选择") { showPhotoPicker = true }
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.photosPicker(
|
||||
isPresented: $showPhotoPicker,
|
||||
selection: $selectedPhotoItems,
|
||||
maxSelectionCount: 1,
|
||||
matching: .images
|
||||
)
|
||||
.sheet(isPresented: $showCamera) {
|
||||
CameraPicker { image in
|
||||
if let image = image {
|
||||
selectedImages = [image]
|
||||
showPreview = true
|
||||
}
|
||||
.zIndex(10)
|
||||
showCamera = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.alert("修改昵称", isPresented: $showNicknameAlert) {
|
||||
nicknameAlertContent(viewStore: viewStore)
|
||||
} message: {
|
||||
Text("昵称最长15个字符")
|
||||
}
|
||||
.sheet(isPresented: userAgreementBinding(viewStore: viewStore)) {
|
||||
WebView(url: URL(string: "https://www.yana.com/user-agreement")!)
|
||||
}
|
||||
.sheet(isPresented: privacyPolicyBinding(viewStore: viewStore)) {
|
||||
WebView(url: URL(string: "https://www.yana.com/privacy-policy")!)
|
||||
}
|
||||
.onChange(of: showImagePicker) { newValue in
|
||||
if newValue {
|
||||
pickerStore.send(.inner(.showActionSheet(true)))
|
||||
.fullScreenCover(isPresented: $showPreview) {
|
||||
ImagePreviewView(
|
||||
images: selectedImages,
|
||||
currentIndex: .constant(0),
|
||||
onConfirm: {
|
||||
if let image = selectedImages.first, let data = image.jpegData(compressionQuality: 0.8) {
|
||||
viewStore.send(AppSettingFeature.Action.avatarSelected(data))
|
||||
}
|
||||
showPreview = false
|
||||
},
|
||||
onCancel: {
|
||||
showPreview = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.onChange(of: selectedPhotoItems) { items in
|
||||
guard !items.isEmpty else { return }
|
||||
isLoading = true
|
||||
selectedImages = []
|
||||
let group = DispatchGroup()
|
||||
var tempImages: [UIImage] = []
|
||||
for item in items {
|
||||
group.enter()
|
||||
item.loadTransferable(type: Data.self) { result in
|
||||
defer { group.leave() }
|
||||
if let data = try? result.get(), let uiImage = UIImage(data: data) {
|
||||
tempImages.append(uiImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
DispatchQueue.global().async {
|
||||
group.wait()
|
||||
DispatchQueue.main.async {
|
||||
selectedImages = tempImages
|
||||
isLoading = false
|
||||
showPreview = !tempImages.isEmpty
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(isPresented: Binding<Bool>(
|
||||
get: { errorMessage != nil },
|
||||
set: { if !$0 { errorMessage = nil } }
|
||||
)) {
|
||||
Alert(title: Text("错误"), message: Text(errorMessage ?? ""), dismissButton: .default(Text("确定")))
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.alert("修改昵称", isPresented: $showNicknameAlert) {
|
||||
nicknameAlertContent(viewStore: viewStore)
|
||||
} message: {
|
||||
Text("昵称最长15个字符")
|
||||
}
|
||||
.sheet(isPresented: userAgreementBinding(viewStore: viewStore)) {
|
||||
WebView(url: URL(string: "https://www.yana.com/user-agreement")!)
|
||||
}
|
||||
.sheet(isPresented: privacyPolicyBinding(viewStore: viewStore)) {
|
||||
WebView(url: URL(string: "https://www.yana.com/privacy-policy")!)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,12 +150,9 @@ struct AppSettingView: View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
avatarImageView(viewStore: viewStore)
|
||||
.onTapGesture {
|
||||
showImagePicker = true
|
||||
}
|
||||
cameraButton
|
||||
.onTapGesture {
|
||||
showImagePicker = true
|
||||
showActionSheet = true
|
||||
}
|
||||
cameraButton(viewStore: viewStore)
|
||||
}
|
||||
.padding(.top, 24)
|
||||
}
|
||||
@@ -98,7 +160,7 @@ struct AppSettingView: View {
|
||||
// MARK: - 头像图片视图
|
||||
@ViewBuilder
|
||||
private func avatarImageView(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||
if viewStore.isLoadingUserInfo {
|
||||
if viewStore.isUploadingAvatar || viewStore.isLoadingUserInfo {
|
||||
loadingAvatarView
|
||||
} else if let avatarURLString = viewStore.avatarURL, !avatarURLString.isEmpty, let avatarURL = URL(string: avatarURLString) {
|
||||
networkAvatarView(url: avatarURL)
|
||||
@@ -145,8 +207,10 @@ struct AppSettingView: View {
|
||||
}
|
||||
|
||||
// MARK: - 相机按钮
|
||||
private var cameraButton: some View {
|
||||
Button(action: {}) {
|
||||
private func cameraButton(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||
Button(action: {
|
||||
showActionSheet = true
|
||||
}) {
|
||||
ZStack {
|
||||
Circle().fill(Color.purple).frame(width: 36, height: 36)
|
||||
Image(systemName: "camera.fill")
|
||||
@@ -336,7 +400,7 @@ struct AppSettingView: View {
|
||||
}
|
||||
|
||||
// MARK: - 图片处理
|
||||
private func loadAndProcessImage(item: PhotosPickerItem, completion: @escaping (Data?) -> Void) {
|
||||
private func loadAndProcessImage(item: PhotosPickerItem, completion: @escaping @Sendable (Data?) -> Void) {
|
||||
item.loadTransferable(type: Data.self) { result in
|
||||
guard let data = try? result.get(), let uiImage = UIImage(data: data) else {
|
||||
completion(nil)
|
||||
|
@@ -15,7 +15,7 @@ public struct ImagePickerWithPreviewState: Equatable {
|
||||
public var showCamera: Bool = false
|
||||
public var showPreview: Bool = false
|
||||
public var isLoading: Bool = false
|
||||
public var errorMessage: String?
|
||||
public var errorMessage: String? = nil
|
||||
public var selectedPhotoItems: [PhotosPickerItem] = []
|
||||
public var selectedImages: [UIImage] = []
|
||||
public var cameraImage: UIImage? = nil
|
||||
@@ -41,6 +41,7 @@ public enum ImagePickerWithPreviewAction: Equatable {
|
||||
case setPreviewIndex(Int)
|
||||
case setShowCamera(Bool)
|
||||
case setShowPhotoPicker(Bool)
|
||||
case reset
|
||||
}
|
||||
|
||||
public enum ImageSource: Equatable {
|
||||
@@ -128,6 +129,10 @@ public struct ImagePickerWithPreviewReducer: Reducer {
|
||||
case .setShowPhotoPicker(let show):
|
||||
state.inner.showPhotoPicker = show
|
||||
return .none
|
||||
case .reset:
|
||||
let mode = state.inner.selectionMode
|
||||
state.inner = ImagePickerWithPreviewState(selectionMode: mode)
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -5,13 +5,15 @@ import PhotosUI
|
||||
public struct ImagePickerWithPreviewView: View {
|
||||
let store: StoreOf<ImagePickerWithPreviewReducer>
|
||||
let onUpload: ([UIImage]) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@State private var loadedImages: [UIImage] = []
|
||||
@State private var isLoadingImages: Bool = false
|
||||
|
||||
public init(store: StoreOf<ImagePickerWithPreviewReducer>, onUpload: @escaping ([UIImage]) -> Void) {
|
||||
public init(store: StoreOf<ImagePickerWithPreviewReducer>, onUpload: @escaping ([UIImage]) -> Void, onCancel: @escaping () -> Void) {
|
||||
self.store = store
|
||||
self.onUpload = onUpload
|
||||
self.onCancel = onCancel
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
@@ -20,7 +22,8 @@ public struct ImagePickerWithPreviewView: View {
|
||||
Color.clear
|
||||
LoadingView(isLoading: viewStore.inner.isLoading || isLoadingImages)
|
||||
}
|
||||
.modifier(ActionSheetModifier(viewStore: viewStore))
|
||||
.background(.clear)
|
||||
.modifier(ActionSheetModifier(viewStore: viewStore, onCancel: onCancel))
|
||||
.modifier(CameraSheetModifier(viewStore: viewStore))
|
||||
.modifier(PhotosPickerModifier(viewStore: viewStore, loadedImages: $loadedImages, isLoadingImages: $isLoadingImages))
|
||||
.modifier(PreviewCoverModifier(viewStore: viewStore, loadedImages: loadedImages, onUpload: onUpload))
|
||||
@@ -46,6 +49,7 @@ private struct LoadingView: View {
|
||||
|
||||
private struct ActionSheetModifier: ViewModifier {
|
||||
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
|
||||
let onCancel: () -> Void
|
||||
func body(content: Content) -> some View {
|
||||
content.confirmationDialog(
|
||||
"请选择图片来源",
|
||||
@@ -57,7 +61,7 @@ private struct ActionSheetModifier: ViewModifier {
|
||||
) {
|
||||
Button("拍照") { viewStore.send(.inner(.selectSource(.camera))) }
|
||||
Button("从相册选择") { viewStore.send(.inner(.selectSource(.photoLibrary))) }
|
||||
Button("取消", role: .cancel) {}
|
||||
Button("取消", role: .cancel) { onCancel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,4 +185,4 @@ private struct ErrorToastModifier: ViewModifier {
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -33,18 +33,7 @@ struct InternalMainView: View {
|
||||
GeometryReader { geometry in
|
||||
contentView(geometry: geometry, viewStore: viewStore)
|
||||
.navigationDestination(for: MainFeature.Destination.self) { destination in
|
||||
let view: AnyView
|
||||
switch destination {
|
||||
case .appSetting:
|
||||
if let appSettingStore = store.scope(state: \.appSettingState, action: \.appSettingAction) {
|
||||
view = AnyView(AppSettingView(store: appSettingStore))
|
||||
} else {
|
||||
view = AnyView(Text("appSettingState is nil"))
|
||||
}
|
||||
case .testView:
|
||||
view = AnyView(TestView())
|
||||
}
|
||||
return view
|
||||
DestinationView(destination: destination, store: self.store)
|
||||
}
|
||||
.onChange(of: path) { newPath in
|
||||
viewStore.send(.navigationPathChanged(newPath))
|
||||
@@ -63,6 +52,28 @@ struct InternalMainView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct DestinationView: View {
|
||||
let destination: MainFeature.Destination
|
||||
let store: StoreOf<MainFeature>
|
||||
|
||||
var body: some View {
|
||||
switch destination {
|
||||
case .appSetting:
|
||||
IfLetStore(
|
||||
store.scope(state: \.appSettingState, action: \.appSettingAction),
|
||||
then: { store in
|
||||
WithPerceptionTracking {
|
||||
AppSettingView(store: store)
|
||||
}
|
||||
},
|
||||
else: { Text("appSettingState is nil") }
|
||||
)
|
||||
case .testView:
|
||||
TestView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func contentView(geometry: GeometryProxy, viewStore: ViewStoreOf<MainFeature>) -> some View {
|
||||
WithPerceptionTracking {
|
||||
ZStack {
|
||||
@@ -74,31 +85,45 @@ struct InternalMainView: View {
|
||||
.clipped()
|
||||
.ignoresSafeArea(.all)
|
||||
// 主内容
|
||||
ZStack {
|
||||
FeedListView(store: store.scope(
|
||||
state: \.feedList,
|
||||
action: \.feedList
|
||||
))
|
||||
.isHidden(viewStore.selectedTab != .feed)
|
||||
MeView(
|
||||
store: store.scope(
|
||||
state: \.me,
|
||||
action: \.me
|
||||
)
|
||||
)
|
||||
.isHidden(viewStore.selectedTab != .other)
|
||||
}
|
||||
MainContentView(
|
||||
store: store,
|
||||
selectedTab: viewStore.selectedTab
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
// 底部导航栏
|
||||
VStack {
|
||||
Spacer()
|
||||
BottomTabView(selectedTab: viewStore.binding(
|
||||
get: { Tab(rawValue: $0.selectedTab.rawValue) ?? .feed },
|
||||
send: { MainFeature.Action.selectTab(MainFeature.Tab(rawValue: $0.rawValue) ?? .feed) }
|
||||
))
|
||||
BottomTabView(selectedTab: viewStore.binding(
|
||||
get: { Tab(rawValue: $0.selectedTab.rawValue) ?? .feed },
|
||||
send: { MainFeature.Action.selectTab(MainFeature.Tab(rawValue: $0.rawValue) ?? .feed) }
|
||||
))
|
||||
}
|
||||
.padding(.bottom, geometry.safeAreaInsets.bottom + 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MainContentView: View {
|
||||
let store: StoreOf<MainFeature>
|
||||
let selectedTab: MainFeature.Tab
|
||||
var body: some View {
|
||||
Group {
|
||||
if selectedTab == .feed {
|
||||
FeedListView(store: store.scope(
|
||||
state: \.feedList,
|
||||
action: \.feedList
|
||||
))
|
||||
} else if selectedTab == .other {
|
||||
MeView(
|
||||
store: store.scope(
|
||||
state: \.me,
|
||||
action: \.me
|
||||
)
|
||||
)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user