跳到主要内容

11 篇博文 含有标签「ios」

查看所有标签

SwiftUI弹窗使用指南:从基础到高级应用

· 阅读需 13 分钟
Web developer & UI designer

一、概述

在iOS应用开发中,弹窗(Modal)是用户界面交互的重要组成部分。SwiftUI提供了多种弹窗实现方式,包括Sheet、Alert、ActionSheet、FullScreenCover等。合理使用弹窗可以提升用户体验,但过度使用也可能造成界面混乱。

本文将详细介绍SwiftUI中各种弹窗的使用方法,从基础用法到高级技巧,帮助开发者掌握弹窗的最佳实践。

二、SwiftUI弹窗类型概览

2.1 主要弹窗类型

弹窗类型用途特点
Sheet半屏弹窗从底部滑出,支持手势关闭
FullScreenCover全屏弹窗覆盖整个屏幕
Alert警告弹窗系统样式,用于确认操作
ActionSheet操作选择底部弹出操作列表
Popover气泡弹窗iPad上显示,iPhone上类似Sheet

2.2 弹窗选择指南

  • Sheet:适合表单输入、设置页面、详情展示
  • FullScreenCover:适合登录页面、引导页面、全屏内容
  • Alert:适合确认删除、错误提示、重要通知
  • ActionSheet:适合操作选择、分享功能
  • Popover:适合iPad上的辅助信息显示

三、基础弹窗实现

3.1 Sheet - 半屏弹窗

import SwiftUI

struct ContentView: View {
@State private var showingSheet = false

var body: some View {
VStack {
Button("显示Sheet") {
showingSheet = true
}
.padding()
}
.sheet(isPresented: $showingSheet) {
SheetView()
}
}
}

struct SheetView: View {
@Environment(\.presentationMode) var presentationMode
@State private var text = ""

var body: some View {
NavigationView {
VStack {
TextField("输入内容", text: $text)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()

Spacer()
}
.navigationTitle("Sheet弹窗")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("取消") {
presentationMode.wrappedValue.dismiss()
}
}

ToolbarItem(placement: .navigationBarTrailing) {
Button("保存") {
// 保存逻辑
presentationMode.wrappedValue.dismiss()
}
}
}
}
}
}

3.2 FullScreenCover - 全屏弹窗

struct ContentView: View {
@State private var showingFullScreen = false

var body: some View {
VStack {
Button("显示全屏弹窗") {
showingFullScreen = true
}
.padding()
}
.fullScreenCover(isPresented: $showingFullScreen) {
FullScreenView()
}
}
}

struct FullScreenView: View {
@Environment(\.presentationMode) var presentationMode

var body: some View {
NavigationView {
VStack {
Text("这是全屏弹窗内容")
.font(.title)
.padding()

Spacer()
}
.navigationTitle("全屏弹窗")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("关闭") {
presentationMode.wrappedValue.dismiss()
}
}
}
}
}
}

3.3 Alert - 警告弹窗

struct ContentView: View {
@State private var showingAlert = false
@State private var alertMessage = ""

var body: some View {
VStack {
Button("显示警告") {
alertMessage = "这是一个警告信息"
showingAlert = true
}
.padding()

Button("删除确认") {
alertMessage = "确定要删除这个项目吗?"
showingAlert = true
}
.foregroundColor(.red)
.padding()
}
.alert("提示", isPresented: $showingAlert) {
Button("确定") {
// 确定操作
}
Button("取消", role: .cancel) {
// 取消操作
}
} message: {
Text(alertMessage)
}
}
}

3.4 ActionSheet - 操作选择

struct ContentView: View {
@State private var showingActionSheet = false

var body: some View {
VStack {
Button("显示操作选择") {
showingActionSheet = true
}
.padding()
}
.actionSheet(isPresented: $showingActionSheet) {
ActionSheet(
title: Text("选择操作"),
message: Text("请选择您要执行的操作"),
buttons: [
.default(Text("编辑")) {
// 编辑操作
},
.destructive(Text("删除")) {
// 删除操作
},
.cancel(Text("取消"))
]
)
}
}
}

四、高级弹窗技巧

4.1 带参数的弹窗

struct ContentView: View {
@State private var selectedItem: String?

var body: some View {
VStack {
Button("编辑项目A") {
selectedItem = "项目A"
}
.padding()

Button("编辑项目B") {
selectedItem = "项目B"
}
.padding()
}
.sheet(item: $selectedItem) { item in
EditView(item: item)
}
}
}

struct EditView: View {
let item: String
@Environment(\.presentationMode) var presentationMode
@State private var editedText = ""

var body: some View {
NavigationView {
VStack {
Text("编辑: \(item)")
.font(.title2)
.padding()

TextField("输入新内容", text: $editedText)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()

Spacer()
}
.navigationTitle("编辑")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("取消") {
presentationMode.wrappedValue.dismiss()
}
}

ToolbarItem(placement: .navigationBarTrailing) {
Button("保存") {
// 保存逻辑
presentationMode.wrappedValue.dismiss()
}
}
}
}
}
}

// 让String遵循Identifiable协议
extension String: Identifiable {
public var id: String { self }
}

4.2 自定义弹窗样式

struct CustomModalView: View {
@Binding var isPresented: Bool
let content: AnyView

var body: some View {
ZStack {
// 背景遮罩
Color.black.opacity(0.3)
.ignoresSafeArea()
.onTapGesture {
isPresented = false
}

// 弹窗内容
VStack {
content
}
.background(Color.white)
.cornerRadius(16)
.shadow(radius: 10)
.padding()
}
.animation(.easeInOut(duration: 0.3), value: isPresented)
}
}

// 使用自定义弹窗
struct ContentView: View {
@State private var showingCustomModal = false

var body: some View {
VStack {
Button("显示自定义弹窗") {
showingCustomModal = true
}
.padding()
}
.overlay(
Group {
if showingCustomModal {
CustomModalView(isPresented: $showingCustomModal) {
AnyView(
VStack {
Text("自定义弹窗")
.font(.title2)
.padding()

Text("这是自定义样式的弹窗内容")
.padding()

Button("关闭") {
showingCustomModal = false
}
.padding()
}
)
}
}
}
)
}
}

4.3 弹窗状态管理

class ModalManager: ObservableObject {
@Published var showingSheet = false
@Published var showingAlert = false
@Published var showingActionSheet = false
@Published var alertMessage = ""
@Published var selectedItem: String?

func showSheet() {
showingSheet = true
}

func showAlert(message: String) {
alertMessage = message
showingAlert = true
}

func showActionSheet() {
showingActionSheet = true
}

func editItem(_ item: String) {
selectedItem = item
}
}

struct ContentView: View {
@StateObject private var modalManager = ModalManager()

var body: some View {
VStack(spacing: 20) {
Button("显示Sheet") {
modalManager.showSheet()
}

Button("显示警告") {
modalManager.showAlert(message: "这是一个警告信息")
}

Button("显示操作选择") {
modalManager.showActionSheet()
}

Button("编辑项目") {
modalManager.editItem("测试项目")
}
}
.padding()
.sheet(isPresented: $modalManager.showingSheet) {
SheetView()
}
.alert("提示", isPresented: $modalManager.showingAlert) {
Button("确定") { }
} message: {
Text(modalManager.alertMessage)
}
.actionSheet(isPresented: $modalManager.showingActionSheet) {
ActionSheet(
title: Text("选择操作"),
buttons: [
.default(Text("操作1")) { },
.default(Text("操作2")) { },
.cancel(Text("取消"))
]
)
}
.sheet(item: $modalManager.selectedItem) { item in
EditView(item: item)
}
}
}

五、实际应用场景

5.1 用户设置页面

struct SettingsView: View {
@State private var showingProfileSheet = false
@State private var showingNotificationSheet = false
@State private var showingAboutSheet = false

var body: some View {
NavigationView {
List {
Section("账户") {
Button("个人资料") {
showingProfileSheet = true
}
.foregroundColor(.primary)

Button("通知设置") {
showingNotificationSheet = true
}
.foregroundColor(.primary)
}

Section("应用") {
Button("关于应用") {
showingAboutSheet = true
}
.foregroundColor(.primary)
}
}
.navigationTitle("设置")
}
.sheet(isPresented: $showingProfileSheet) {
ProfileView()
}
.sheet(isPresented: $showingNotificationSheet) {
NotificationSettingsView()
}
.sheet(isPresented: $showingAboutSheet) {
AboutView()
}
}
}

struct ProfileView: View {
@Environment(\.presentationMode) var presentationMode
@State private var name = ""
@State private var email = ""

var body: some View {
NavigationView {
Form {
Section("基本信息") {
TextField("姓名", text: $name)
TextField("邮箱", text: $email)
.keyboardType(.emailAddress)
}
}
.navigationTitle("个人资料")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("取消") {
presentationMode.wrappedValue.dismiss()
}
}

ToolbarItem(placement: .navigationBarTrailing) {
Button("保存") {
// 保存逻辑
presentationMode.wrappedValue.dismiss()
}
}
}
}
}
}

5.2 数据编辑表单

struct TodoListView: View {
@State private var todos = ["学习SwiftUI", "完成项目", "写博客"]
@State private var showingAddSheet = false
@State private var showingEditSheet = false
@State private var selectedTodo: String?
@State private var showingDeleteAlert = false
@State private var todoToDelete: String?

var body: some View {
NavigationView {
List {
ForEach(todos, id: \.self) { todo in
Text(todo)
.onTapGesture {
selectedTodo = todo
showingEditSheet = true
}
.contextMenu {
Button("编辑") {
selectedTodo = todo
showingEditSheet = true
}

Button("删除", role: .destructive) {
todoToDelete = todo
showingDeleteAlert = true
}
}
}
}
.navigationTitle("待办事项")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("添加") {
showingAddSheet = true
}
}
}
}
.sheet(isPresented: $showingAddSheet) {
AddTodoView(todos: $todos)
}
.sheet(item: $selectedTodo) { todo in
EditTodoView(todo: todo, todos: $todos)
}
.alert("确认删除", isPresented: $showingDeleteAlert) {
Button("删除", role: .destructive) {
if let todo = todoToDelete {
todos.removeAll { $0 == todo }
}
}
Button("取消", role: .cancel) { }
} message: {
Text("确定要删除这个待办事项吗?")
}
}
}

struct AddTodoView: View {
@Binding var todos: [String]
@Environment(\.presentationMode) var presentationMode
@State private var newTodo = ""

var body: some View {
NavigationView {
VStack {
TextField("输入新的待办事项", text: $newTodo)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()

Spacer()
}
.navigationTitle("添加待办事项")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("取消") {
presentationMode.wrappedValue.dismiss()
}
}

ToolbarItem(placement: .navigationBarTrailing) {
Button("保存") {
if !newTodo.isEmpty {
todos.append(newTodo)
}
presentationMode.wrappedValue.dismiss()
}
.disabled(newTodo.isEmpty)
}
}
}
}
}

struct EditTodoView: View {
let todo: String
@Binding var todos: [String]
@Environment(\.presentationMode) var presentationMode
@State private var editedTodo: String

init(todo: String, todos: Binding<[String]>) {
self.todo = todo
self._todos = todos
self._editedTodo = State(initialValue: todo)
}

var body: some View {
NavigationView {
VStack {
TextField("编辑待办事项", text: $editedTodo)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()

Spacer()
}
.navigationTitle("编辑待办事项")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("取消") {
presentationMode.wrappedValue.dismiss()
}
}

ToolbarItem(placement: .navigationBarTrailing) {
Button("保存") {
if let index = todos.firstIndex(of: todo) {
todos[index] = editedTodo
}
presentationMode.wrappedValue.dismiss()
}
}
}
}
}
}

5.3 图片选择器

import PhotosUI

struct ImagePickerView: View {
@State private var showingImagePicker = false
@State private var selectedImage: UIImage?
@State private var showingImageSheet = false

var body: some View {
VStack {
if let image = selectedImage {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 200)
.cornerRadius(10)
} else {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
.frame(height: 200)
.overlay(
Text("点击选择图片")
.foregroundColor(.gray)
)
}

Button("选择图片") {
showingImagePicker = true
}
.padding()

if selectedImage != nil {
Button("查看图片") {
showingImageSheet = true
}
.padding()
}
}
.sheet(isPresented: $showingImagePicker) {
ImagePicker(selectedImage: $selectedImage)
}
.sheet(isPresented: $showingImageSheet) {
if let image = selectedImage {
ImageDetailView(image: image)
}
}
}
}

struct ImagePicker: UIViewControllerRepresentable {
@Binding var selectedImage: UIImage?
@Environment(\.presentationMode) var presentationMode

func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = .photoLibrary
return picker
}

func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: ImagePicker

init(_ parent: ImagePicker) {
self.parent = parent
}

func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
if let image = info[.originalImage] as? UIImage {
parent.selectedImage = image
}
parent.presentationMode.wrappedValue.dismiss()
}

func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.presentationMode.wrappedValue.dismiss()
}
}
}

struct ImageDetailView: View {
let image: UIImage
@Environment(\.presentationMode) var presentationMode

var body: some View {
NavigationView {
VStack {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.padding()

Spacer()
}
.navigationTitle("图片详情")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("关闭") {
presentationMode.wrappedValue.dismiss()
}
}
}
}
}
}

六、弹窗动画和过渡效果

6.1 自定义动画

struct AnimatedModalView: View {
@Binding var isPresented: Bool
@State private var offset: CGFloat = 1000

var body: some View {
ZStack {
// 背景遮罩
Color.black.opacity(0.3)
.ignoresSafeArea()
.opacity(isPresented ? 1 : 0)
.animation(.easeInOut(duration: 0.3), value: isPresented)

// 弹窗内容
VStack {
Text("动画弹窗")
.font(.title2)
.padding()

Text("这是一个带有自定义动画的弹窗")
.padding()

Button("关闭") {
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
isPresented = false
}
}
.padding()
}
.background(Color.white)
.cornerRadius(16)
.shadow(radius: 10)
.padding()
.offset(y: offset)
.onAppear {
if isPresented {
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
offset = 0
}
}
}
.onChange(of: isPresented) { newValue in
if !newValue {
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
offset = 1000
}
}
}
}
}
}

6.2 弹窗链式调用

struct ChainedModalsView: View {
@State private var showingFirstModal = false
@State private var showingSecondModal = false
@State private var showingThirdModal = false

var body: some View {
VStack {
Button("开始流程") {
showingFirstModal = true
}
.padding()
}
.sheet(isPresented: $showingFirstModal) {
FirstModalView(
showingSecondModal: $showingSecondModal,
isPresented: $showingFirstModal
)
}
.sheet(isPresented: $showingSecondModal) {
SecondModalView(
showingThirdModal: $showingThirdModal,
isPresented: $showingSecondModal
)
}
.sheet(isPresented: $showingThirdModal) {
ThirdModalView(isPresented: $showingThirdModal)
}
}
}

struct FirstModalView: View {
@Binding var showingSecondModal: Bool
@Binding var isPresented: Bool
@Environment(\.presentationMode) var presentationMode

var body: some View {
NavigationView {
VStack {
Text("第一步")
.font(.title)
.padding()

Text("这是流程的第一步")
.padding()

Button("下一步") {
presentationMode.wrappedValue.dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
showingSecondModal = true
}
}
.padding()

Spacer()
}
.navigationTitle("第一步")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("取消") {
isPresented = false
}
}
}
}
}
}

struct SecondModalView: View {
@Binding var showingThirdModal: Bool
@Binding var isPresented: Bool
@Environment(\.presentationMode) var presentationMode

var body: some View {
NavigationView {
VStack {
Text("第二步")
.font(.title)
.padding()

Text("这是流程的第二步")
.padding()

Button("下一步") {
presentationMode.wrappedValue.dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
showingThirdModal = true
}
}
.padding()

Spacer()
}
.navigationTitle("第二步")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("取消") {
isPresented = false
}
}
}
}
}
}

struct ThirdModalView: View {
@Binding var isPresented: Bool
@Environment(\.presentationMode) var presentationMode

var body: some View {
NavigationView {
VStack {
Text("第三步")
.font(.title)
.padding()

Text("这是流程的最后一步")
.padding()

Button("完成") {
isPresented = false
}
.padding()

Spacer()
}
.navigationTitle("第三步")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("取消") {
isPresented = false
}
}
}
}
}
}

七、最佳实践和注意事项

7.1 弹窗使用原则

// ✅ 好的做法
struct GoodModalUsage: View {
@State private var showingSettings = false

var body: some View {
VStack {
// 明确的触发按钮
Button("设置") {
showingSettings = true
}
}
.sheet(isPresented: $showingSettings) {
SettingsView()
}
}
}

// ❌ 避免的做法
struct BadModalUsage: View {
@State private var showingModal = false

var body: some View {
VStack {
// 自动弹出弹窗,没有用户触发
Text("内容")
}
.onAppear {
showingModal = true // 不推荐
}
.sheet(isPresented: $showingModal) {
SomeView()
}
}
}

7.2 内存管理

class ModalDataManager: ObservableObject {
@Published var data: [String] = []

deinit {
print("ModalDataManager被释放")
}
}

struct ModalWithDataManager: View {
@State private var showingModal = false

var body: some View {
VStack {
Button("显示弹窗") {
showingModal = true
}
}
.sheet(isPresented: $showingModal) {
// 弹窗关闭时,数据管理器会自动释放
ModalContentView()
}
}
}

struct ModalContentView: View {
@StateObject private var dataManager = ModalDataManager()

var body: some View {
NavigationView {
VStack {
Text("弹窗内容")
// 使用dataManager
}
.navigationTitle("弹窗")
}
}
}

7.3 错误处理

struct ModalWithErrorHandling: View {
@State private var showingModal = false
@State private var showingErrorAlert = false
@State private var errorMessage = ""

var body: some View {
VStack {
Button("显示弹窗") {
showingModal = true
}
}
.sheet(isPresented: $showingModal) {
ModalWithErrorView(
showingErrorAlert: $showingErrorAlert,
errorMessage: $errorMessage
)
}
.alert("错误", isPresented: $showingErrorAlert) {
Button("确定") { }
} message: {
Text(errorMessage)
}
}
}

struct ModalWithErrorView: View {
@Binding var showingErrorAlert: Bool
@Binding var errorMessage: String
@Environment(\.presentationMode) var presentationMode

var body: some View {
NavigationView {
VStack {
Button("触发错误") {
errorMessage = "这是一个错误示例"
showingErrorAlert = true
}
.padding()

Spacer()
}
.navigationTitle("错误处理")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("关闭") {
presentationMode.wrappedValue.dismiss()
}
}
}
}
}
}

7.4 性能优化

// 使用LazyVStack优化长列表弹窗
struct OptimizedModalView: View {
@State private var showingModal = false

var body: some View {
VStack {
Button("显示优化弹窗") {
showingModal = true
}
}
.sheet(isPresented: $showingModal) {
LazyVStack {
ForEach(0..<1000, id: \.self) { index in
Text("项目 \(index)")
.padding()
}
}
.navigationTitle("优化列表")
}
}
}

// 使用@StateObject避免重复创建
struct OptimizedDataModal: View {
@State private var showingModal = false

var body: some View {
VStack {
Button("显示数据弹窗") {
showingModal = true
}
}
.sheet(isPresented: $showingModal) {
DataModalView()
}
}
}

struct DataModalView: View {
// 使用@StateObject确保只创建一次
@StateObject private var dataManager = DataManager()

var body: some View {
NavigationView {
VStack {
Text("数据弹窗")
// 使用dataManager
}
.navigationTitle("数据")
}
}
}

八、常见问题和解决方案

8.1 弹窗不显示

// 问题:弹窗状态管理错误
struct ProblematicModal: View {
@State private var showingModal = false

var body: some View {
VStack {
Button("显示弹窗") {
// ❌ 错误:在同一个视图更新中修改状态
showingModal = true
// 其他操作...
}
}
.sheet(isPresented: $showingModal) {
SomeView()
}
}
}

// 解决方案:使用DispatchQueue延迟执行
struct FixedModal: View {
@State private var showingModal = false

var body: some View {
VStack {
Button("显示弹窗") {
// ✅ 正确:延迟执行状态更新
DispatchQueue.main.async {
showingModal = true
}
}
}
.sheet(isPresented: $showingModal) {
SomeView()
}
}
}

8.2 弹窗重复显示

// 问题:多个弹窗同时显示
struct MultipleModals: View {
@State private var showingSheet = false
@State private var showingAlert = false

var body: some View {
VStack {
Button("显示Sheet") {
showingSheet = true
}

Button("显示Alert") {
showingAlert = true
}
}
.sheet(isPresented: $showingSheet) {
SomeView()
}
.alert("提示", isPresented: $showingAlert) {
Button("确定") { }
}
}
}

// 解决方案:使用弹窗管理器
class ModalCoordinator: ObservableObject {
@Published var activeModal: ModalType?

enum ModalType {
case sheet
case alert
}

func showModal(_ type: ModalType) {
activeModal = type
}

func dismissModal() {
activeModal = nil
}
}

struct CoordinatedModals: View {
@StateObject private var modalCoordinator = ModalCoordinator()

var body: some View {
VStack {
Button("显示Sheet") {
modalCoordinator.showModal(.sheet)
}

Button("显示Alert") {
modalCoordinator.showModal(.alert)
}
}
.sheet(isPresented: Binding(
get: { modalCoordinator.activeModal == .sheet },
set: { _ in modalCoordinator.dismissModal() }
)) {
SomeView()
}
.alert("提示", isPresented: Binding(
get: { modalCoordinator.activeModal == .alert },
set: { _ in modalCoordinator.dismissModal() }
)) {
Button("确定") { }
}
}
}

九、总结

SwiftUI提供了丰富的弹窗组件,合理使用这些组件可以大大提升用户体验。本文涵盖了:

主要弹窗类型:

  • Sheet:半屏弹窗,适合表单和设置
  • FullScreenCover:全屏弹窗,适合重要流程
  • Alert:系统警告,适合确认操作
  • ActionSheet:操作选择,适合多选项
  • Popover:气泡弹窗,适合iPad辅助信息

关键要点:

  1. 选择合适的弹窗类型:根据使用场景选择最合适的弹窗
  2. 合理管理弹窗状态:使用@State、@Binding或状态管理器
  3. 提供良好的用户体验:清晰的触发方式、适当的动画效果
  4. 处理错误和异常:完善的错误处理机制
  5. 优化性能:避免不必要的重复创建和内存泄漏

最佳实践:

  • 弹窗应该有明确的触发方式
  • 提供清晰的关闭方式
  • 使用适当的动画效果
  • 处理网络状态和错误情况
  • 考虑不同设备尺寸的适配

通过掌握这些弹窗使用技巧,您可以创建更加流畅和用户友好的iOS应用界面。


本文提供了完整的代码示例和最佳实践,开发者可以根据具体需求进行调整和扩展。

iOS开发:使用iCloud实现跨设备数据共享

· 阅读需 9 分钟
Web developer & UI designer

一、概述

在现代移动应用开发中,跨设备数据同步已成为用户体验的重要组成部分。Apple的iCloud服务为iOS开发者提供了强大的数据同步解决方案,让用户可以在iPhone、iPad、Mac等设备间无缝同步应用数据。

本文将详细介绍如何在iOS应用中使用iCloud实现跨设备数据共享,包括CloudKit和Core Data with CloudKit两种主要方案。

二、iCloud数据同步方案对比

2.1 CloudKit

  • 适用场景:结构化数据、用户生成内容
  • 优势:功能强大、支持复杂查询、可扩展性好
  • 劣势:学习曲线较陡峭、需要处理网络状态

2.2 Core Data with CloudKit

  • 适用场景:本地数据存储 + 云端同步
  • 优势:集成度高、自动处理冲突、开发简单
  • 劣势:功能相对有限、定制化程度较低

三、项目配置

3.1 启用iCloud功能

首先在Xcode中配置iCloud功能:

  1. 选择项目 → Target → Signing & Capabilities
  2. 点击 "+ Capability"
  3. 添加 "iCloud" 功能
  4. 勾选 "CloudKit" 选项

3.2 配置CloudKit容器

// 在AppDelegate或SceneDelegate中配置CloudKit
import CloudKit

class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

// 检查iCloud可用性
checkiCloudAvailability()

return true
}

private func checkiCloudAvailability() {
CKContainer.default().accountStatus { status, error in
DispatchQueue.main.async {
switch status {
case .available:
print("iCloud账户可用")
case .noAccount:
print("未登录iCloud账户")
case .restricted:
print("iCloud账户受限")
case .couldNotDetermine:
print("无法确定iCloud状态")
@unknown default:
print("未知iCloud状态")
}
}
}
}
}

四、CloudKit实现方案

4.1 定义数据模型

import CloudKit

// 定义CloudKit记录类型
struct TodoItem {
let id: CKRecord.ID
let title: String
let isCompleted: Bool
let createdAt: Date
let updatedAt: Date

// 转换为CloudKit记录
func toCKRecord() -> CKRecord {
let record = CKRecord(recordType: "TodoItem", recordID: id)
record["title"] = title
record["isCompleted"] = isCompleted
record["createdAt"] = createdAt
record["updatedAt"] = updatedAt
return record
}

// 从CloudKit记录创建
init(from record: CKRecord) {
self.id = record.recordID
self.title = record["title"] as? String ?? ""
self.isCompleted = record["isCompleted"] as? Bool ?? false
self.createdAt = record["createdAt"] as? Date ?? Date()
self.updatedAt = record["updatedAt"] as? Date ?? Date()
}
}

4.2 创建CloudKit管理器

import CloudKit
import Combine

class CloudKitManager: ObservableObject {
private let container = CKContainer.default()
private let privateDatabase: CKDatabase

@Published var items: [TodoItem] = []
@Published var isLoading = false
@Published var errorMessage: String?

init() {
self.privateDatabase = container.privateCloudDatabase
}

// 保存数据到iCloud
func saveItem(_ item: TodoItem) {
isLoading = true

let record = item.toCKRecord()

privateDatabase.save(record) { [weak self] savedRecord, error in
DispatchQueue.main.async {
self?.isLoading = false

if let error = error {
self?.errorMessage = "保存失败: \(error.localizedDescription)"
} else {
self?.errorMessage = nil
// 重新加载数据
self?.fetchItems()
}
}
}
}

// 从iCloud获取数据
func fetchItems() {
isLoading = true

let query = CKQuery(recordType: "TodoItem", predicate: NSPredicate(value: true))
query.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]

privateDatabase.perform(query, inZoneWith: nil) { [weak self] records, error in
DispatchQueue.main.async {
self?.isLoading = false

if let error = error {
self?.errorMessage = "获取数据失败: \(error.localizedDescription)"
} else {
self?.errorMessage = nil
self?.items = records?.map { TodoItem(from: $0) } ?? []
}
}
}
}

// 删除数据
func deleteItem(_ item: TodoItem) {
isLoading = true

privateDatabase.delete(withRecordID: item.id) { [weak self] recordID, error in
DispatchQueue.main.async {
self?.isLoading = false

if let error = error {
self?.errorMessage = "删除失败: \(error.localizedDescription)"
} else {
self?.errorMessage = nil
// 重新加载数据
self?.fetchItems()
}
}
}
}
}

4.3 实现数据同步

import CloudKit

extension CloudKitManager {

// 监听远程数据变化
func startRemoteNotifications() {
let subscription = CKQuerySubscription(
recordType: "TodoItem",
predicate: NSPredicate(value: true),
subscriptionID: "TodoItemChanges",
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)

let notificationInfo = CKSubscription.NotificationInfo()
notificationInfo.shouldSendContentAvailable = true
subscription.notificationInfo = notificationInfo

privateDatabase.save(subscription) { subscription, error in
if let error = error {
print("订阅失败: \(error.localizedDescription)")
} else {
print("订阅成功")
}
}
}

// 处理远程通知
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) {
let notification = CKNotification(fromRemoteNotificationDictionary: userInfo)

if let queryNotification = notification as? CKQueryNotification {
// 根据通知类型处理数据变化
switch queryNotification.notificationType {
case .recordCreated, .recordUpdated:
fetchItems()
case .recordDeleted:
// 从本地数据中移除对应记录
if let recordID = queryNotification.recordID {
items.removeAll { $0.id == recordID }
}
@unknown default:
break
}
}
}
}

五、Core Data with CloudKit实现方案

5.1 配置Core Data Stack

import CoreData
import CloudKit

class CoreDataStack {
static let shared = CoreDataStack()

lazy var persistentContainer: NSPersistentCloudKitContainer = {
let container = NSPersistentCloudKitContainer(name: "DataModel")

// 配置CloudKit
guard let description = container.persistentStoreDescriptions.first else {
fatalError("无法获取持久化存储描述")
}

description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)

container.loadPersistentStores { _, error in
if let error = error {
fatalError("Core Data加载失败: \(error)")
}
}

container.viewContext.automaticallyMergesChangesFromParent = true

return container
}()

var context: NSManagedObjectContext {
return persistentContainer.viewContext
}

func save() {
if context.hasChanges {
do {
try context.save()
} catch {
print("保存失败: \(error)")
}
}
}
}

5.2 定义Core Data模型

import CoreData
import Foundation

@objc(TodoItem)
public class TodoItem: NSManagedObject {

}

extension TodoItem {
@nonobjc public class func fetchRequest() -> NSFetchRequest<TodoItem> {
return NSFetchRequest<TodoItem>(entityName: "TodoItem")
}

@NSManaged public var id: UUID?
@NSManaged public var title: String?
@NSManaged public var isCompleted: Bool
@NSManaged public var createdAt: Date?
@NSManaged public var updatedAt: Date?
}

extension TodoItem: Identifiable {

}

5.3 数据操作管理器

import CoreData
import Combine

class TodoDataManager: ObservableObject {
private let coreDataStack = CoreDataStack.shared
private var cancellables = Set<AnyCancellable>()

@Published var items: [TodoItem] = []
@Published var isLoading = false

init() {
fetchItems()
setupRemoteChangeNotification()
}

// 获取数据
func fetchItems() {
let request: NSFetchRequest<TodoItem> = TodoItem.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]

do {
items = try coreDataStack.context.fetch(request)
} catch {
print("获取数据失败: \(error)")
}
}

// 添加新项目
func addItem(title: String) {
let newItem = TodoItem(context: coreDataStack.context)
newItem.id = UUID()
newItem.title = title
newItem.isCompleted = false
newItem.createdAt = Date()
newItem.updatedAt = Date()

coreDataStack.save()
fetchItems()
}

// 更新项目
func updateItem(_ item: TodoItem, title: String? = nil, isCompleted: Bool? = nil) {
if let title = title {
item.title = title
}
if let isCompleted = isCompleted {
item.isCompleted = isCompleted
}
item.updatedAt = Date()

coreDataStack.save()
fetchItems()
}

// 删除项目
func deleteItem(_ item: TodoItem) {
coreDataStack.context.delete(item)
coreDataStack.save()
fetchItems()
}

// 监听远程变化
private func setupRemoteChangeNotification() {
NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange)
.sink { [weak self] _ in
DispatchQueue.main.async {
self?.fetchItems()
}
}
.store(in: &cancellables)
}
}

六、SwiftUI界面实现

6.1 主界面

import SwiftUI

struct ContentView: View {
@StateObject private var dataManager = TodoDataManager()
@State private var showingAddItem = false

var body: some View {
NavigationView {
VStack {
if dataManager.isLoading {
ProgressView("同步中...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List {
ForEach(dataManager.items, id: \.id) { item in
TodoItemRow(item: item, dataManager: dataManager)
}
.onDelete(perform: deleteItems)
}
}
}
.navigationTitle("待办事项")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("添加") {
showingAddItem = true
}
}
}
.sheet(isPresented: $showingAddItem) {
AddItemView(dataManager: dataManager)
}
}
}

private func deleteItems(offsets: IndexSet) {
for index in offsets {
dataManager.deleteItem(dataManager.items[index])
}
}
}

6.2 待办事项行视图

struct TodoItemRow: View {
let item: TodoItem
let dataManager: TodoDataManager
@State private var isEditing = false
@State private var editedTitle = ""

var body: some View {
HStack {
Button(action: {
dataManager.updateItem(item, isCompleted: !item.isCompleted)
}) {
Image(systemName: item.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundColor(item.isCompleted ? .green : .gray)
}

if isEditing {
TextField("输入标题", text: $editedTitle)
.textFieldStyle(RoundedBorderTextFieldStyle())
.onSubmit {
saveEdit()
}
} else {
Text(item.title ?? "")
.strikethrough(item.isCompleted)
.foregroundColor(item.isCompleted ? .gray : .primary)
.onTapGesture {
startEditing()
}
}

Spacer()

if let updatedAt = item.updatedAt {
Text(updatedAt, style: .relative)
.font(.caption)
.foregroundColor(.secondary)
}
}
.swipeActions(edge: .trailing) {
Button("删除", role: .destructive) {
dataManager.deleteItem(item)
}
}
}

private func startEditing() {
editedTitle = item.title ?? ""
isEditing = true
}

private func saveEdit() {
dataManager.updateItem(item, title: editedTitle)
isEditing = false
}
}

6.3 添加项目视图

struct AddItemView: View {
let dataManager: TodoDataManager
@State private var title = ""
@Environment(\.presentationMode) var presentationMode

var body: some View {
NavigationView {
VStack {
TextField("输入待办事项", text: $title)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()

Spacer()
}
.navigationTitle("添加项目")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("取消") {
presentationMode.wrappedValue.dismiss()
}
}

ToolbarItem(placement: .navigationBarTrailing) {
Button("保存") {
if !title.isEmpty {
dataManager.addItem(title: title)
presentationMode.wrappedValue.dismiss()
}
}
.disabled(title.isEmpty)
}
}
}
}
}

七、测试和调试

7.1 测试数据同步

import XCTest
@testable import YourApp

class CloudKitTests: XCTestCase {
var cloudKitManager: CloudKitManager!

override func setUp() {
super.setUp()
cloudKitManager = CloudKitManager()
}

func testSaveAndFetchItem() {
let expectation = XCTestExpectation(description: "保存和获取数据")

let testItem = TodoItem(
id: CKRecord.ID(),
title: "测试项目",
isCompleted: false,
createdAt: Date(),
updatedAt: Date()
)

// 保存数据
cloudKitManager.saveItem(testItem)

// 等待保存完成
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
// 获取数据
self.cloudKitManager.fetchItems()

// 验证数据
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
XCTAssertFalse(self.cloudKitManager.items.isEmpty)
expectation.fulfill()
}
}

wait(for: [expectation], timeout: 10)
}
}

7.2 调试技巧

// 添加详细的日志记录
class CloudKitManager {
private func logOperation(_ operation: String, success: Bool, error: Error? = nil) {
let status = success ? "成功" : "失败"
print("CloudKit操作 [\(operation)] \(status)")

if let error = error {
print("错误详情: \(error.localizedDescription)")
}
}

func saveItem(_ item: TodoItem) {
logOperation("保存项目", success: false) // 开始操作

let record = item.toCKRecord()

privateDatabase.save(record) { [weak self] savedRecord, error in
DispatchQueue.main.async {
let success = error == nil
self?.logOperation("保存项目", success: success, error: error)

if success {
self?.fetchItems()
}
}
}
}
}

八、最佳实践和注意事项

8.1 性能优化

  1. 批量操作:尽量使用批量操作减少网络请求
  2. 本地缓存:优先显示本地数据,后台同步云端数据
  3. 增量同步:只同步变化的数据,避免全量同步

8.2 错误处理

extension CloudKitManager {
private func handleCloudKitError(_ error: Error) {
if let ckError = error as? CKError {
switch ckError.code {
case .networkUnavailable:
errorMessage = "网络不可用,请检查网络连接"
case .notAuthenticated:
errorMessage = "请登录iCloud账户"
case .quotaExceeded:
errorMessage = "iCloud存储空间不足"
case .serviceUnavailable:
errorMessage = "iCloud服务暂时不可用"
default:
errorMessage = "同步失败: \(ckError.localizedDescription)"
}
} else {
errorMessage = "未知错误: \(error.localizedDescription)"
}
}
}

8.3 用户体验优化

  1. 离线支持:确保应用在无网络时仍可正常使用
  2. 同步状态提示:向用户显示同步状态和进度
  3. 冲突解决:提供友好的数据冲突解决界面

九、总结

通过本文的详细介绍,我们学习了在iOS应用中使用iCloud实现跨设备数据共享的两种主要方案:

  1. CloudKit方案:适合需要复杂查询和高度定制化的场景
  2. Core Data with CloudKit方案:适合简单的数据同步需求

关键要点:

  • 正确配置iCloud功能和CloudKit容器
  • 实现健壮的错误处理和网络状态检测
  • 提供良好的用户体验和同步状态反馈
  • 遵循Apple的设计指南和最佳实践

通过合理选择方案并正确实现,可以为用户提供无缝的跨设备数据同步体验,大大提升应用的价值和用户粘性。


本文提供了完整的代码示例和实现步骤,开发者可以根据具体需求进行调整和扩展。

把矩形放到合适的位置上

· 阅读需 1 分钟
Web developer & UI designer

问题

如下图:有两个 VStack 容器,高和宽分别为 100 和 150,容器的上方是文字标题,标题下方分别有一个红色矩形和一个蓝色矩形,请用红色矩形将标题下方的容器填满,将蓝色矩形的高宽设置为容器的二分之一,放在距容器左侧 10 个单位,距顶部 10 个单位的位置。

Docusaurus Plushie

思路

使用 GeometryReader 读取父容器的尺寸,依此来定位矩形的位置。

解答

struct ContentView: View {
var body: some View {
VStack{
VStack {
Text("矩形一")
.padding(.top)
Rectangle()
.fill(Color.red)
}
.frame(width:250,height: 200)
.border(Color.black)
VStack{
Text("矩形二")
.padding(.top)
//GeometryReader会读取父容器的尺寸,然后根据父容器的尺寸设置矩形的大小及位置
GeometryReader{geometry in
Rectangle()
.path(in: CGRect(
x: 10, y: 10, width: geometry.size.width/2, height: geometry.size.height/2
)
)
.fill(Color.blue)
}
}
.frame(width:250,height: 200)
.border(Color.black)
}
}
}

将JSON数据转换为结构体数据

· 阅读需 2 分钟
Web developer & UI designer

问题

假设我们从服务器获取了一个 JSON 类型的数据如下:

{"name": "Jone","age": 17}

请使用 swift 将其转换为结构体类型的数据,其中年龄转换为整型数据,并打印出来。

思路

首先将此 JSON 数据定义为字符串类型的数据,将其转换成 Data 型。

然后定义一个结构体类型的数据模型,使其遵守 Codable 协议,只要遵守这些协议才能进行 json 与模型之间的编码与解码。

接下来我们就可以进行讲 json 解码并映射成模型,打印出年龄值。

解答

//将 json 数据转换为字符串类型的数据,方法是使用三个双引号包裹,属于多行字符串,引号中什么样,打印出来就是什么样,格式都不会变。
let res = """
{
"name": "Jone",
"age": 17
}
"""

//使用字符串.data()方法对字符串进行转换转换之后打印显示 32bytes
let data = res.data(using: .utf8)!

//Codable 协议其实就是遵守一个关于解码的协议和一个关于编码的协议,只要遵守这些协议才能进行 Json 与模型之间的编码与解码。

struct Student : Codable{
let name : String
let age : Int
}

//接下来我们就可以将 json 解码并映射成模型
let decoder = JSONDecoder()

//!的意思是强制解码,因为我们知道 data 中有值
let stu = try! decoder.decode(Student.self, from: data)

//可以看到,stu.age 已经成为整型。
print(stu.age)

开发一个计算器APP——Part1:界面实现

· 阅读需 3 分钟
Web developer & UI designer

问题

使用 HStack 和 VStack 来实现计算器的界面。

Docusaurus Plushie

思路

任何复杂的界面都可以将其分解为单一的 HStack 和 VStack 来实现,计算器界面也一样,先用 HStack 将横排的按钮组合,然后再将 HStack 放入到 VStack 中。

Docusaurus Plushie

步骤一:按照界面元素从上到下的顺序开始写起,使用 HStack 和 VStack 组织好界面。

步骤二:通过创建自定义视图 View 和自定义 ViewModifier 将重复的控件(Text())和修饰符合并,简化代码。其中计算器数字部分“0”的样式,通过在 ViewModifier 中设置布尔值加三元运算符的方法来实现。

步骤三:由于自定义的 ViewModifier 有三种,分别对应计算器按钮的三种不同样式,还需要进一步合并,接下来使用双重数组加 ForEach 语句,并通过定义枚举数据类型将计算器按钮数据归类来完成合并。

解答

import SwiftUI

//计算器界面的主视图。
struct ContentView: View {
var body: some View {
VStack(spacing : 20){
Text("0")
.frame(maxWidth: .infinity, maxHeight: 200, alignment: .trailing)
.padding(.trailing, 50)
//系统自带的文字大小
.font(.system(size: 60))
.foregroundColor(Color("resule_fg"))
ForEach(0..<calcu_text.count){ i in
HStack{
ForEach(0..<calcu_text[i].count){ j in
CustomButton(calcu_text[i][j])
//这里是每个按钮的视图。
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color("calc_bg"))
//注意顺序
.edgesIgnoringSafeArea(.all)
}
}


//使用View协议,生成一个CustomButton复合视图,这个视图生成了每个按钮的内容和样式。
struct CustomButton : View {

let type : CalcButtonType

init(_ type : CalcButtonType) {
self.type = type
}

var body: some View{

Text(self.type.text)
.modifier(CalcModifier(type:self.type))

}
}


//使用View协议,生成一个CalcModifier复合修饰符来修饰每个按钮。
struct CalcModifier : ViewModifier {

let type : CalcButtonType
//让结构体的类型为枚举型

func body(content: Content) -> some View {
content
.frame(width: 80,height: 80)
.font(.title)
.background(self.type.bg_color)
//从固定的样式,变为动态样式
.foregroundColor(self.type.fg_color)
.cornerRadius(40.0)
}
}


//使用枚举类型数据,对复合修饰符和内容进行操作。
enum CalcButtonType{
//因为要给按钮设置三种样式,那么将之前数组类型的数据定义为枚举型,更方便使用。

case number(_ text:String)
//number是枚举的成员,关联值是一个字符串类型
case calc_opterator(_ text:String)
case calc_s_opterator(_ text:String)

var text : String {
//定义一个属性,这个属性直接返回text.

switch self {
case let.number(text) :
return text
case let.calc_opterator(text) :
return text
case let.calc_s_opterator(text) :
return text

}

}

var fg_color : Color{

switch self {
case .number(_) :
//如果匹配number,那么返回Color("darkgrey_operator_fg")
return Color("darkgrey_operator_fg")
case .calc_opterator(_) :
return Color("yellow_operator_fg")
case .calc_s_opterator(_) :
return Color("s_operator_fg")

}

}

var bg_color : Color{

switch self {
case .number(_) :
return Color("darkgrey_operator_bg")
case .calc_opterator(_) :
return Color("yellow_operator_bg")
case .calc_s_opterator(_) :
return Color("s_operator_bg")

}

}

}

prefix operator %
prefix operator -
prefix operator +

prefix func % (right : String) -> CalcButtonType{
return .calc_s_opterator(right)

}
prefix func - (right : String) -> CalcButtonType{
return .calc_opterator(right)

}
prefix func + (right : String) -> CalcButtonType{
return .number(right)

}
//将数据定义为枚举类型。
let calcu_text : [[CalcButtonType]] = [[%"AC",%"+/-",%"%",-"+"],[+"7",+"8",+"9",-"x"],[+"4",+"5",+"6",-"2"],[+"AC",+"+/-",+"%",-"2"],[+"0",+"+/-",-"="]]


struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

将数据传递到待办事项列表中

· 阅读需 3 分钟
Web developer & UI designer

问题

创建如下图所示的待办事项列表视图,并将标题数据传递到待办事项中。

Docusaurus Plushie

思路

每一项待办事项所包含的数据都是一样的,因此我们可以通过创建一个 class 类型的数据,其中包括字符串数据类型的标题、日期类型的时间和布尔值类型的选框,然后使用 SwiftUI 中的 ForEach 语句对数据进行遍历,从而完成数据的传递。

解答

import SwiftUI

//让类从NSObject, NSCoder继承功能,保证下次打开APP时,数据还在
class Todo : NSCoder{

//每一个事项的标题
var title : String = ""
//每一个事项的日期
var dueDate : Date = Date()
//每一个事项是否打勾
var checked : Bool = false
//i用来记录某一待办事项是列表中的第几个待办事项
var i : Int = 0

init(title: String, dueDate: Date, checked: Bool){
//self.title代表类里面的title等于初始化函数里的参数title,如果这样写就好理解很多:
//self.title = a
self.title = title
self.dueDate = dueDate
self.checked = false
}
}

//var emptyTodo : Todo = Todo(title: "", dueDate: Date())

var exampleTodos: [Todo] = [
Todo(title: "看电影", dueDate: Date(), checked: false),
Todo(title: "看话剧", dueDate: Date(), checked: false),
Todo(title: "完成swiftUI", dueDate: Date(), checked: false),
Todo(title: "完成Sketch四个界面", dueDate: Date(), checked: false),
Todo(title: "洗锅", dueDate: Date(), checked: false),
Todo(title: "画画", dueDate: Date(), checked: false)
]

NavigationView{
ScrollView{
ForEach(0..<exampleTodos.count){todoIndex in
VStack{
HStack{
Button(action: {

})
{
//左边的部分,包括蓝边、项目标题及时间
HStack{
//蓝边
VStack{
Rectangle()
.fill(Color.blue)
//frame就是元素的大小
.frame(width: 8,height: 74)
}

//项目标题及时间
VStack{

//项目标题
HStack{
Text(exampleTodos[todoIndex].title)
.font(.headline)
.foregroundColor(exampleTodos[todoIndex].checked ? .gray : .blue)
//把标题挤到左边
Spacer()
}
//时间
HStack{
Image(systemName: "clock")
.resizable()
.frame(width: 12,height: 12)
Text("2020/1/21")
.font(.subheadline)
//不设置frame,无限大,把时间挤到左边
Spacer()
}
.foregroundColor(Color.gray)
}
//padding是元素和frame之间的边距
.padding(.leading,5)

}

}
Button(action: {

})
{
VStack{
Image(systemName: exampleTodos[todoIndex].checked ? "checkmark.square" : "square")
.resizable()
.frame(width: 24, height: 24)
.foregroundColor(Color.gray)
.animation(.easeInOut)
}
.padding()
}

}
//冒号前面表示checked为真时的背景色,后面为否时的背景色
.background(Color(exampleTodos[todoIndex].checked ? "SingleItemBgChecked" : "SingleItemBg"))
.cornerRadius(10)
//元素内边距
.padding(10)
}
}
}
.navigationBarTitle("待办事项")
}

制作开发APP点餐列表

· 阅读需 2 分钟
Web developer & UI designer

问题

使用 SwiftUI 制作开发如下图的点餐列表。

Docusaurus Plushie

思路

使用 SwiftUI 中 TextField、Stepper、Picker 控件设计界面,结合自定义类数据将数据传入控件,完成开发。

解答(XCode11.4)

import SwiftUI

//ObservableObject使得Order成为被监听的对象

class Order : ObservableObject{
//在添加ObservableObject协议的基础上,要改变值的变量必须加@Published
let types : Array = ["香草","巧克力","芒果","草莓"]
var type : Int = 0
//@Published和@State的作用一样,当变量的值改变,视图中也会同步改变
@Published var number : Int = 0
@Published var text : String = "Name"
}

struct ContentView: View {
//在结构体内容声明一个变量的类型为Order类,这样就可以用到外部的自定义数据类型
@ObservedObject var order = Order()
var body: some View{
NavigationView{
Form{
TextField("name", text: $order.text)
//Stepper是一个函数,其中的参数value必须要绑定一个值,rang给定一个范围.,访问变量值的时候不用加$,但是绑定,也就是改变的时候要加$,无论加不加$,它们的值都是一个值,所以这里前面加,后面不加。
Stepper("数量:\(order.number)", value: $order.number, in: 010)
//selection表示已经选择的口味,
Picker("请选择口味", selection: $order.type){
// ForEach部分表示选择的范围,这其中的order.types只是将值传进去,而不会变化,因此不需要加$符号,从这里开始,是点击进入之后的内容
ForEach(0..<order.types.count){i in
Text(self.order.types[i])
}
}
}
.navigationBarTitle("CupcakeCorner")
}
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

如何在SwiftUI创建选项卡栏

· 阅读需 2 分钟
Web developer & UI designer

在SwiftUI中创建Tab Bar非常简单——以下是如何来做。

选项卡栏(在UIKit中称为UITabBars)非常适合为用户提供在不同视图之间快速切换的能力。以下是如何在你的SwiftUI应用程序中创建选项卡栏。

第一步:将包含子视图的整个视图封装到TabView中,这样会自动为我们创建一个具有完整功能的选项卡条。

第二步:将子视图放入到Tab View中。这将自动为TabBar中的每个子视图创建一个“槽”。

第三步:通过添加.tabItem修饰符为每个子视图创建一个Tab选项。你可以为每个选项卡选择任何图标和标签,SwiftUI会知道每个对象应该拥有哪个索引,并根据当前的选择更新状态!然后,您可以使用状态值,就像是我们对显示选定颜色的文本所做的操作一样。

struct ContentView: View {
var body: some View {
//1. 创建一个TabView
TabView {
//2. 将子视图放入TabView中
Text("Home View")
//3. 为每一个子视图创建一个tab bar
.tabItem {
Image(systemName: "house")
Text("Home")
}
Text("Settings View")
.tabItem {
Image(systemName: "gear")
Text("Settings")
}
}
}
}

在Xcode中创建单一待办事项视图

· 阅读需 1 分钟
Web developer & UI designer

问题

创建一个如下图的待办事项视图。

Docusaurus Plushie

思路

使用 SwiftUI 中的 HStack 和 VStack 来实现此视图,需要对扩张性控件和收缩性控件熟练把握。

解答

HStack{
Button(action: {})
{
//左边的部分,包括蓝边、项目标题及时间
HStack{
//蓝边
VStack{
Rectangle()
.fill(Color.blue)
//frame就是元素的大小
.frame(width: 8,height: 74)
}
//项目标题及时间
VStack{
//项目标题
HStack{
Text("看话剧")
.font(.headline)
//把标题挤到左边
Spacer()
}
//时间
HStack{
Image(systemName: "clock")
.resizable()
.frame(width: 12,height: 12)
Text("1月18日")
.font(.subheadline)
//不设置frame,无限大,把时间挤到左边
Spacer()
}
.foregroundColor(Color.gray)
}
//padding是元素和frame之间的边距
.padding(.leading,5)
}
}
Button(action: {})
{
VStack{
Image(systemName: "square")
.resizable()
.frame(width: 24, height: 24)
.foregroundColor(Color.gray)
}
.padding()
}
}
.background(Color("SingleItemBg"))
.cornerRadius(10)
//元素内边距
.padding(20)

SwiftUI—如何构建一个图像

· 阅读需 1 分钟
Web developer & UI designer

Docusaurus Plushie

想知道如何在 SwiftUI 中正确地构建图像吗?这就是你要的!

第一步:将.resizable 修饰符应用于图像对象上,此修饰符将调整图像的大小和 frame 到合适的大小。

第二步::当你改变图像的大小时,通常需要保持原来的尺寸。为此,添加.aspectRatio 修饰符。你可以选择.fill 内容模式,让整个 frame 被图像填满,而.fit 内容模式确保整个图像在 frame 内。

第三步:现在您可以声明图像的 frame 修饰符。

第四步:增加.clipped 修饰符,以确保你的图像超过 frame 的任何部分被切断。

import SwiftUI
struct ContentView : View {
var body: some View {
Image("dog")
//Enable size editing for the Image
.resizable()
//Define which method to use to keep the original dimensions when resizing
.aspectRatio(contentMode: .fill)
//Declare the frame for your image
.frame(width: 380, height: 280)
//Cut out the exceeding parts of the image
.clipped()
}
}