跳到主要内容

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的设计指南和最佳实践

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


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

重复的原则——从音乐聊到UI设计

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

最近在读《聆听音乐》这本书。忽然觉得音乐的创作和UI设计存在着很多相似性,一些基本的创作原则是共通的。

对于音乐来讲,一个非常重要的方面就是曲式,也就是乐曲的结构,无论是流行音乐还是古典音乐,曲式都是非常重要的一个方面。从简单的二段式、三段式,到主题与变奏、回旋曲直至篇幅更大、结构更为复杂的奏鸣曲—快板曲式,其中一个重要的创作手法就是重复。我们经常可以很容易的发现,一个主题在整个乐曲中不断地再现,这种再现有时是简单的重复,但大多数时候是变化后的重复。

那么为什么要重复?一首乐曲可不可以从开始到结束完全不重复?从一段乐思开始不断地进行线性的发展,从始至终完全是新的旋律?当然可以,虽然这样的曲式运用的并不多。浪漫主义时期根据诗歌谱曲的艺术歌曲会见到这样的例子,通常把这种没有明显重复,旋律和和声总在不断变化的作品,称为通谱体。

但从大量的乐曲上来看,重复的创造手法,无疑具有巨大的价值。原因就在于重复能够让整个作品更有统一感和完整性,它更符合人类审美心理上的需求。人类会本能地对未知事物的整体进行细分,将其梳理为更小的结构,便于理解和认知。对于毫无头绪,错综复杂完全理不出结构的内容,会本能地加以排斥,如果在结构上不能很好的认知,会觉得缺乏秩序和美感。

这样的心理放在UI设计上也是同样的道理。一个好的设计作品抛开功能性先不谈,从视觉审美上来讲无疑和音乐作品一样,需要传达一种情绪和感受。当然,相较于视觉领域的设计,音乐的这种目的并不是总是那么直接,尤其在浪漫主义时期以前。而对于设计来讲,这种情绪和感受往往是力度越强效果越好。

因此,在设计方案的构思中,为了更好的表达情绪,通常先要确立一种风格,这样才能更好的为设计提供明确的方向。之后就是在大的风格框架内不断运用重复的设计手法,持续的加强设计的力度,让期望的某种情绪表达的更加充分。因此无论是排版、配色、形状还是质感,都需要不断重复。

我们以下面这个设计作品为为例,对设计中重复的手法进行具体的说明。

Docusaurus Plushie

从整体情绪上来看,这个设计给人干净柔和的感受。这是和版式上大面积留白的不断重复运用直接相关的。突出的表现在每个信息区域的顶部,也就是它的标题部分。

拿banner下面的第一个信息区域来讲,和通常的设计相比,这里有意识的和banner区域留出了更多的间距。标题处的单行主标题和两行副标题垂直相叠,字号拉开差距,小范围进行对比增加层次感后向左对齐,在右侧留出了非常大的空白区域。

同样的,在内页的设计中也重复的运用了这样的设计手法。无论标题是向左对齐还是居中对齐,每个区域之间的留白空间非常大,但每个区域内的元素的间距都相对紧密,否则在板式上会显得过于松散,这样有张有弛的结构,形成了紧-疏-紧-疏的节奏,这和乐曲中A-B-A结构的曲式有着异曲同工之妙。

再来看色彩方案。很明显,这个设计采用了补色的色彩搭配方案,其中橙色为主色,而青色处于相对次要的位置。这样的配色突出表现在banner区域,可以观察到,图片中山崖及房屋部分为橙色,占据大约2/3的面积,作为图片背景的森林为青色,占据1/3的面积。需要特别指出的是,图片上的色彩大概率经过了后期处理,尤其是青色的森林树木,在原片更偏绿色的基础上将色相向蓝色方向调整了一些,目的是和橙色更加搭配。

页面中除了最显眼的banner图片,还有很多图片,如果不在色彩上进行重复,会让设计变得杂乱不堪,因此可以看到,无论任何一张图片,都重复的运用了这个色彩方案,拿右下角的建筑图来说,天空和建筑的部分色彩上虽然纯度很低,但明显偏向橙色。那么青色在哪里?仔细观看就能发现图片右下角部分的路面是呈青色的,面积虽然很小,但依然和整体的色彩方案保持一致。另外前面讲到的留白也在图片上得到了重复,首页的四张小图顶部都保留了纯净的天空,使得图片主角突出,并强化了干净的设计感受。但不得不说一句,内页的设计明显可以看出不如首页用心,无论在色彩的重复上还是图片的选择上控制不够,因此相较于首页,内页稍显杂乱。

除了色彩上的重复,在形状上,也可以观察到这样的重复。可以看到圆角矩形在设计中不断地出现。无论是图片还是按钮,都使用了圆角元素。相较于直角,圆角更加柔和,并且显得干净。这和我们期望在旅行中得到舒适、周到、温暖的服务的感受其实是有关联的。

从这个案例,我们可以看到,从色彩到版式,设计中应用了大量的重复的手法,无论是音乐还是设计,重复是创作中非常重要的方面。深刻的理解重复的原则,对于设计的提升是必不可少的。

React实战案例:操作数据将其应用到antd的Cascader组件中并通过Cascader组件选项切换日历数据

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

一、需求

将获得的数据转换为antd中Cascader组件的option选项数据,然后再通过Cascader组件某一选项过滤日历的传入数据,在日历上显示。

二、思路

我们先来看看获得的原始数据,calendarEvents是包含着对象的数组类型:

[{
"id": "11ec-b68b-1df198ede54e",
"start": "2022-03-09T05:00:00.000Z",
"end": "2022-03-10T04:59:59.999Z",
"visible_title": null,
"status": "not_started",
"notes": null,
"site_links": [],
"cycle": {
"id": "afb444a0-b68b-1df198ede54e",
"name": "1941",
"__typename": "Cycle"
},
"task_type": {
"id": "ad3f075b-960f789-f74a7e816e7e",
"name": "Scouting B6",
"__typename": "TaskType"
},
"schedule_events": [],
"__typename": "CycleEvent",
"title": "Scouting B6 - 1941",
"allDay": true
},
...
]

而antd的Cascader组件需要的选项Option数据格式是一个有特定键值的数组:

const options = [
{
value: 'zhejiang',
label: 'Zhejiang',
children: [
{
value: 'hangzhou',
label: 'Hangzhou',
children: [
{
value: 'xihu',
label: 'West Lake',
},
],
},
],
},
{
value: 'jiangsu',
label: 'Jiangsu',
children: [
{
value: 'nanjing',
label: 'Nanjing',
children: [
{
value: 'zhonghuamen',
label: 'Zhong Hua Men',
},
],
},
],
},
];

根据需求我们分为两步:

第一步: 我们需要把calendarEvents中键值为"__typename"的值取出,包装成Option数据格式,附上新的键值,传给Cascader组件。

第二步: 获取Cascader组件选择的值,使用它对calendarEvents原始数据进行过滤,获取新的数组,传递给日历组件。当然,此时我们需要声明一个新的组件状态。

三、涉及到的技术

  • lodash中的_.filter_.uniqBy_.orderBy_.map方法
  • react-big-calendar库的使用

四、解决方案

1. mapTaskTypesToCascadeOptions函数将calendarEvents转换为Cascader需要的格式

function mapTaskTypesToCascadeOptions() {
// 过滤原始数据,将calendarEvents中对象task_type属性值不为空的对象取出,形成新数组
const calendarEventsWithTaskType = _.filter(calendarEvents, obj => obj.task_type !== null);
// 再将id重复的去掉
let allTaskTypes = _.uniqBy(_.map(calendarEventsWithTaskType, obj => obj.task_type), 'id');
// 接下来对数组进行排序,按照name升序排序
allTaskTypes = _.orderBy(allTaskTypes, 'name', 'asc');
// 最后赋予新的键值将该数组映射为符合Cascade选项的数组
return _.map(allTaskTypes, task => {
return {
id: _.get(task, 'id', ''),
value: _.get(task, 'name', ''),
label: _.get(task, 'name', ''),
}
})
};

const actionItemCascadeOptions = mapTaskTypesToCascadeOptions();

2. 给Cascader组件传入准备好的数据

<Cascader
{...classes('calendar-filter')}
// 这里option就可以使用已经整理好的数据了
options={actionItemCascadeOptions}
expandTrigger='hover'
onChange={onActionItemChange}
changeOnSelect={true}
placeholder='Select Task Type'
showSearch={{ filter }}
/>

3. 在Cascader的回调函数中,对calendarEvents进行过滤

function onActionItemChange(value) {
// 如果value有值
if (value.length === 1) {
// 对calendarEvents进行过滤,返回其中的对象的task_type.name和值和选择的value值相等的数组
const filteredEvents = _.filter(calendarEvents, (event) => {
return _.get(event, 'task_type.name', '') === value[0];
});
// 更改日历的数据为filteredEvents
setCalendarData(filteredEvents);
} else {
// 否则日历使用原来的calendarEvents
setCalendarData(calendarEvents);
}
};

4. 声明一个新的状态

// 用来保存日历数据的状态
let [calendarData, setCalendarData] = useState(calendarEvents);

5. 给日历组件传递数据

<Calendar
ref={printableRef}
// 将calendarData传入组件中
events={calendarData}
step={60}
showMultiDayTimes
localizer={localizer}
/>

React实战案例:给表格添加总结栏,并自动求和

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

一、需求

要实现antd表格的自动汇总功能,如果表格中单元格的值为数字的话,将其相加,生成总结栏,展示在表格底部。类似效果如下图所示:

表格总结栏效果图

先来观察我们拿到的表格数据,它的格式是一个包含着对象的数组,其中值为数字的属性是我们要获取的数据,如下所示:

const filledLogs = [
{
"id": "2d1e7f10-57ac-477a-9b1a-6f537e915a30",
"timestamp": "1/27/2022 @ 3:16 AM",
"Tare Weight_unit": "Grams",
"name": "Batch 1844",
"cycle": {
"start": "2021-11-03T15:16:39.024+00:00",
"end": "2022-02-15T16:16:39.024+00:00",
},
"Net Weight_unit": "Grams",
"Net Weight": 77230,
"Tare Weight": 10000,
"Gross Weight": 87230,
"is_queued": false,
"manager": {
"id": "75597beb-769e-4b04-81ae-ef7f5b4e1aa0",
"name": "Jack White",
},
"plant_links": [],
},
// ... 更多数据
]

这里的难点在于我们要实现的表格的数据结构虽然一样,但具体的数据不是固定的,因此不能通过静态的键值来获取数据。

二、思路

由于键值不固定,因此我们需要通过判断对象的值是否为数字来获得对应的key和它的value。

我们的目标是最好能够得到如下的一个对象类型的数据,其中的key为数组中原本的key,它的值为该数组中同一个key的值的总和。

{
"Net Weight": 158101,
"Tare Weight": 54567,
"Gross Weight": 212668
}

三、涉及到的技术

  • lodash 中的 _.forEach()_.isNumber()_.get()_.has() 方法
  • antd 表格组件总结栏 <Table.Summary.Row><Table.Summary.Cell> 和 Typography 组件中的 <Text>

四、解决方案

以下是完整的实现代码:

// filledLog的格式必须是一个包含对象的数组
summary={(filledLogs) => {
// 首先定义一个空对象
let numberSums: { [key: string]: number; } = {};

// 遍历数组filledLogs,获取到其中的每一个对象
_.forEach(filledLogs, obj => {
// 继续遍历每一个对象,获取其中的key和value
_.forEach(obj, (value, key) => {
// 判断该value是否为数字,如果为数字
if (_.isNumber(value)) {
// 通过该数字对应的key在numberSums中取值,默认值为0,赋值给sum变量
const sum = _.get(numberSums, `${key}`, 0)
// 然后将sum和该key对应的值相加,再以该key为键值,保存到numberSums中
// 例如,遍历时,第一次,sum值为0,obj[key]为数组中该key对应的数字,相加后保存到numberSums对象中
// 第二次,sum被取出,再和key值一致的值相加后保存,覆盖掉原值
numberSums[key] = sum + obj[key];
}
})
});

return (
<Table.Summary.Row>
<Table.Summary.Cell index={0}>Total</Table.Summary.Cell>
<Table.Summary.Cell index={1}></Table.Summary.Cell>
{
// filledLogHeaders是表头的数据,格式也是包含对象的数组
// 遍历filledLogHeaders数组,拿到每一个对象
filledLogHeaders.map((item, index) => {
return (
<Table.Summary.Cell index={index}>
<Text>
{
// 检查numberSums是否有以item.title为key的值
_.has(numberSums, item.title)
// 如果有则显示该值,否则显示'-'
? numberSums[item.title] : '-'
}
</Text>
</Table.Summary.Cell>
)
})
}
</Table.Summary.Row>
)
}}

五、总结

这个解决方案的核心思路是:

  1. 动态识别数字字段:通过 _.isNumber() 判断对象中的值是否为数字类型
  2. 累加计算:使用 _.forEach() 遍历数据,将相同键名的数字值进行累加
  3. 渲染总结行:使用 antd 的 Table.Summary 组件在表格底部显示汇总结果

这种方法的优势在于:

  • 通用性强:不依赖固定的字段名,适用于任何包含数字字段的数据结构
  • 可维护性好:代码逻辑清晰,易于理解和修改
  • 性能良好:使用 lodash 的高效方法进行数据处理

通过这种方式,我们可以轻松地为任何 antd 表格添加自动汇总功能,提升用户体验。

降低波动,获得长期的复利收益

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

美国普林斯顿大学经济学教授马尔基尔曾说过,绝大多数人这一生犯的最大错误就是没有充分利用复利。

什么是复利?简单地说就是利滚利。也就是上一年(或任何计息周期)的本金所产生的利息计入到下一年度的本金之内再计算利息。这样的计息方式威力巨大。相信我们大部分人都听说过背上了被称之为“驴打滚”的高利贷无法摆脱债务的故事。这里俗称的所谓“驴打滚”的高利贷,其实就是复利。复利的力量在于随着时间的推移,收益或者债务会越来越多,呈几何数的增长。

我们举个简单的例子来直观的感受复利的力量。如下图所示,假设你有1万元本金,按照复利年化收益8%计算,那么在第9年的时候本金就可以翻倍。而越往后,增长的幅度越来越大。到第15年,本金就可以增长超过200%。

Docusaurus Plushie

关于计算翻倍增长,我们通常使用更简单的七二法则。也就是用72除以复利年化收益来计算本金翻倍所需要的时间。同样也可以用时间长度来推算每年的收益率,比如打算用10年时间让本金翻倍,那么就用72÷10,那么你需要在这10年中保持年化收益7.2%。

我们可以用这个公式来计算一下近10年房产的年化收益。假设一套100平米的房子,10年前房子的单价是5000元每平米,10年后上涨到18,000元每平米,房子的总价从50万元增长到180万元。那么按照复利计算的话,年化收益接近14%。这个收益率已经跑赢了市场指数的平均收益,按照复利计算下来,我们就能很明显的看到,房子就是近二十年中收益最好的投资产品。可以这么说,在中国房地产市场飞速发展的这二十多年中,我们每个普通人多多少少都主动或被动的参与了一场关于复利的投资活动。

但目前来看,显然这场活动已经进入下半场。房地产投资这条已经到后半段的鱼,也许还有肉吃,但被刺扎到的概率也在不断增加,是时候将目光转回到资本市场了。在经历了19年和20年的高收益后,21年的资本市场逐渐价值回归,在忍受了漫漫一年的寒冬后,开始进入了可以春播的时段。

而在资本市场,债券和股票是主要的投资产品,它们也都是复利的,是我们每个人都可以参与投资的,几乎没有门槛。但长期以来,我们大多数人却总是无法通过它们获得长期的复利收益。其中根本的原因就在于市场波动太大,无法做到长期持有,虽然市场长期向上,但由于剧烈的涨跌总是让我们拿不住,而一但我们离开了市场,也就错失了利用复利让财富不断增长的机会。

有人就说了,既然长期是上涨的,那就拿着呗,长期持有有什么难的?长期持有的难度有多大,我们举例来说明。

假设你非常看好一家企业并买入了它的股票,如你所愿,不到一年时间,这只股票上涨了40%,看到如此好的收益,你信心满满继续持有,认为它之后还会有更好的表现,但不幸的是随后的一年中,这只股票却下跌了近30%。你坚持价值投资,继续持有,果然随着企业经营状况的改善,第二年这只股票迎来大涨,收益接近100%。此时你开始考虑是不是需要止盈卖出了,因为很明显这只股票已经逐渐恢复走向高估,之后股价回撤的几率将会越来越大。但你最终选择了继续坚持持有,你的考虑是一方面是这只股票的估值还不算高,你觉得短期内的回撤幅度是可以忍受的,另外一方面你不想错失获得复利收益的机会。但不幸的是此后的大半年时间,股价一路下滑,伴随着该公司利空消息频出,这只股票居然又回到了三年前的价格。而你眼睁睁地看着曾经翻倍的收益化为乌有,三年的涨涨跌跌,坚持到最后坚持了一个寂寞。而这只股票的名字叫格力电器。

Docusaurus Plushie

如果18年下半年买入,3年时间格力电器的股价又回到了原点

个股波动大,指数也不例外。拿沪深300指数来举例,2016年全年的回撤幅度接近10%。2018年股灾之年,全年的回撤幅度达到22%以上。虽然接下来的2019年和2020年上涨幅度都在30%以上,但如果我们计算一下近5年沪深300指数的复利年化收益率,大概也就是10%多一点。这样的收益率和如此巨大的波动幅度,残酷的考验着人性,没有几个人能在这样的波动面前无动于衷,保持良好的心态。而在这样的巨大波动面前,要想保持持续的年化收益更是一件非常艰难的事情。

那么我们该怎么做才能够一直呆在市场中,获得长期的复利年化收益?答案很明显,就是要降低波动。不把波动降下来,我们是无法做到长期持有的,那么又该如何降低波动呢?简单的做法就是做好股债配比。和股票相比,债券有更稳定的收益,大概每年4%左右,看起来不高,但是却很稳定,适合用来做资产打底,再搭配股票来提高收益是一个切实可行的办法。

Docusaurus Plushie

上图是用80%的债券搭配20%的股票做出的一个基金组合,从近10年的回测数据看,收益居然跑赢了指数,年化复利收益率达到9.35%,最关键的是,相较于指数,波动非常小,这样的产品才是我们的理想产品,只有先确保了低波动,我们才能长期呆在市场中,获得长期的复利回报。如果我们还能够接受10%以内的回撤幅度,那么通过购买股票仓位再高一点的产品,比如按照股3债7或者股4债6的比例搭配。那么我们就可以在承受波动幅度只有指数波动幅度一半的情况下,获得长期年化超过10%的收益。

一定不要嫌收益太低,要时刻牢记我们不是要追求一时的高收益,而是要充分利用复利来实现资产的持续不断地增长。另外不要高估了自己所能承受的波动幅度。如果有人说能够做到回撤50%都不为所动,那只能说明仓位太轻,仓位越重意味着你越不能忍受亏损。投入100块钱你可以连账户看都不看,投入1万元你就会三五天看看,投入100万你一定会忍不住每天打开帐户看看涨跌,几个点的涨幅就会让你欢欣鼓舞,而只需要一个点的亏损你就会揪心一整天。

因此客观的了解自己到底能承受多大程度的亏损,才是制定投资策略的前提条件。而我们一般人所能忍受的最大回撤,大概就在10%,这还是在有投资经验的前提下。因此适合我们一般人的长期持有的产品其实就在从保本的存款到混合债基之间,个股、指数基金、主动基金都只是短期或者作为配置的产品,并不适合重仓。我们要明白,投资的的成败,最终是看谁跑的更长,而不是跑得更快。

创建网页背景材质时常用到的4种方法

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

在网页设计中创建背景材质通常是根据设计方向展开设计的第一步,也是确定设计基调的重要环节。虽然大多数情况下,网页背景材质是非常细微和低调的,但是由于整个背景的范围较大,所以会给人强烈的心理感受。所以这篇文章中,我会就网页背景材质创建过程中常用到的方法做一总结,希望对于大家在设计中的开头步骤能够给与帮助的参考。

一、使用现成的图案来创建网页背景材质。

1、下载现成的图案样式,或者自己动手创建图案,关于如何自己创建图案,之前我写的一篇文章《在 Photoshop 中创建多种样式的网格背景图案》以及《在 Photoshop 中创建不规则无缝拼接图案》可以作为参考。下载完或者创建好图案之后,在图层样式中选择图案叠加即可。虽然看起来是很简单的一个步骤,但是如果有了高质量的图案样式,效果非常显著。下面是我收集的一些经典的图案文件,其中包括五类图案样式。

2、如果下载的图案色调不符合设计要求的话,可以通过图层混合模式来进行调整。比如我们想要创建浅色的背景材质,但是手头的图案样式是暗色的,如果图案叠加的混合模式为正常的话,创建出来的效果如下图:

Docusaurus Plushie

但是我想用这款图案做出浅色的背景来,该怎么做呢?这时候就可以通过调整图案叠加的混合模式来实现。在背景色不完全是白色的情况下,一般像上面的图案,通过调整混合模式到亮光就能得到如下图的效果。

Docusaurus Plushie

3、还不满意的话,比如想要亮度再稍微再亮一点,还可以通过新建调整图层来实现。在这个例子中,使用色阶调整图层就可以实现我们的设计目标。你还可以通过添加黑白图层来给整个背景材质去色,通过色相饱和度图层调整背景材质的色相以及明暗值。

Docusaurus Plushie

4、通过在图案图层上方新建一个颜色填充图层,调整该图层的图层混合模式,我们可以给图案图层添加一定的颜色。比如加一点黄色进去,或者加一点蓝色。当然这里我们依然可以通过添加色相以及饱和度调整图层来给本来是灰度的材质上色,具体方法就是在色相饱和度调整图层的对话框中勾选着色框,然后调整色相的滑动块来实现。

Docusaurus Plushie

Docusaurus Plushie

Docusaurus Plushie

《在 Photoshop 中创建一个布纹材质的网页》可以作为这种方法的参考教程。

二、应用滤镜来创建网页材质背景。

在使用滤镜来给网页背景添加材质的方法中,最常见到的莫过于使用滤镜中的添加杂色命令来实现需要的效果了。应用步骤为,先将图层转转为智能对象,目的是方便随时调整滤镜值。然后应用滤镜>杂色>添加杂色命令即可,非常简单。一般来说,添加杂色时,滤镜数值的设置非常低,一般使用 1%就够了,而且效果非常好,数值太大材质看上去不够自然。具体设置如下图所示:

Docusaurus Plushie

杂色背景效果如下图:

Docusaurus Plushie

三、使用笔刷来添加质感。

使用笔刷来创建网页背景材质也很简单,一般的步骤是,将前景色调整为白色,选择画笔工具,调整好大小,在新建的图层上随机添加一些笔刷效果,通过多建几个图层,调整不同图层的图透明度可以让添加的材质看上去更自然和真实。之后将图层的混合模式调整为叠加或者柔光让笔刷效果和背景更好的融合。下面的图片是添加了划痕和做旧效果的笔刷后,未更改图层混合模式之前和更改后的效果:

Docusaurus Plushie

Docusaurus Plushie

除了上面的做旧风格常用到这种方法外,笔刷方法的应用还常常出现在水彩风格的网页设计过程中,下面的这两个设计教程充分展示了笔刷在创建背景材质中的方法。

《在 Photoshop 中创建一个水彩风格的网页设计》

《创建一个做旧的,半透明的个人网页》

四、使用材质图片来创建网页背景

比如我们要让网页背景看上去是纸质的感觉。当然可以通过添加不同层次的滤镜效果模拟来实现类似于纸张的材质,但是相较于直接把一张纸质材质的图片拖到文档中,通过调整图层的混合模式以及上面提到的各种调整图层来实现的话,后面的一种方法做出来的效果往往要更真实和自然。类似的还有木质的背景材质的实现。《在 Photoshop 中创建一个游戏界面窗口》这篇文章很好的阐释了这种背景材质设计方法以及上面几种方法的综合运用。

下面的截图展示了木质背景材质的效果:

Docusaurus Plushie

五、总结

实际上,在创建网页背景材质时,上面讲到的方法都会涉及到,只不过依据设计目标,有时只会用到其中的一种方法,比如杂色背景的话,应用添加杂色的滤镜命令往往就够了,有时却要复杂一些,可能上面的方法都会用得到。所以我们要依据总的设计目标来选择性的使用,但是这些方法不用说都是网页设计中必须要掌握的,这样我们的设计手法才能灵活多变。

网页设计流程中常见问题分析及建议

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

从和客户沟通了解客户需求到画出草图进行构思和创意,直至打开Photoshop完成整个的设计,每一个网页设计师都在每一个新的设计项目中不断重复这个过程。整个看上去规范而流程化的工作方式似乎按部就班就能够顺利拿到让大家都满意的结果,但其实在每一个步骤中都存在难度,某一个方面没有做好,可能就会影响到最终设计稿的质量。因此在这篇文章中我就来结合自己的设计体会谈谈这其中存在的常见问题,以及我们应该如何有意识的避免或者解决它们。

这篇文章中的内容我已经在SDC的网页设计讲座中和大家谈到,这次算是对整个讲座内容的总结和归纳。关于网页设计中的问题,我总结了个人认为常见的六个,下面一一道来。

问题一 :和客户沟通的不够充分,导致设计方向出现偏差。

这个问题是在设计流程中出现最多,也最容易导致客户和设计师产生矛盾的问题。最终的结果是客户觉得怎么修改设计都不能让自己满意,而设计师却不胜其烦,认为客户太难说话,原因其实就出在双方的沟通上。

作为客户来说,很多时候他们对于期望的设计产品脑子里往往只有模糊的概念,只能交代给设计师一个过于粗略的设计方向,甚至有些客户提出"先做一稿出来我再看"的要求。遇到这种情况,设计师要清楚客户自己的设计需求是不明晰的,而作为设计师来说,这时就需要积极地引导客户,使他们明晰设计需求,从而避免由于双方未在设计目标和期许上达成一致,造成设计方向上出现偏差,而导致设计中大范围、不断的修改设计稿,甚至一遍一遍的推倒重来的结果。

要使双方对最终的设计都满意,就需要尽可能详细的沟通,客户和设计师之间理解程度越高,最后达成的一致性也越高,这两者是呈正相关的关系。要做到这一点,我个人的方法是在沟通的环节设计系统的调查问卷让客户作答,以帮助客户梳理、明晰设计需求。下面是我经常使用的调查问卷内容,其中需要客户作答7个问题,这些问题基本可以帮助客户和设计师明确设计方向,你也可以依据在和客户沟通中的具体情况修改或者完善这些问题。

当访问者访问你的网站的时候,你希望它们做什么?(比如购买某种产品、服务,或者展示企业的形象等)

网站的主体用户是哪部分人群?(白领年轻女性、有某方面需求的老年人等)

网站的整体风格是什么?当访问者进入你的网站,你希望他们有什么感觉?不希望他们有什么感觉?

举几个你喜欢的网站,并说明原因;例举几个你讨厌的网站,也说明原因。(请将网站链接或者截图粘贴在下方)

网站栏目有哪些?并提供具体信息内容(可粘贴在下方)

请提供网站设计的资料,包括logo,公司图片,产品图片等(打包以附件形式发送至设计师邮箱)

请提供您的联系方式。(QQ、手机或者邮箱)

问题二:跳过网站功能及信息架构上的研究,直接开始视觉创作。

网站的功能和信息架构是网站的核心,一个网站不是单纯的让访问者去感受视觉上的美观,美观永远是第二位的,而功能性却是第一位的。一个企业网站是为了展示企业的形象、售卖企业的产品和服务;一个门户网站是为了更好的提供信息内容,一个个人博客是为了分享个人的观点,树立个人品牌等等,保证了这些功能更好的实现了之后才应该去考虑视觉上是否美观的问题。

我在之前的一篇文章《从千鸟志看网页设计中的功能性》中分析过千鸟的个人博客在功能性和用户体验方面的优点,虽然初次打开该博客并不会被它看上去似乎没怎么设计的灰白界面所吸引,但这个网站良好的用户体验却一定会让你记忆深刻。

更重要的是,网站功能以及它的信息架构对于视觉是有非常重要的影响的,这点我在《网页核心内容对视觉表现的影响》这篇文章中有较为详细的论述,在这里就不再展开了。

问题三:缺乏对网站整体风格的思考和把握,做到哪里算哪里。

这个问题也非常常见,以下是一个设计师发给我的问题及我的答复,应该说非常典型的反映了这个问题。

"想请教你一下,一个学校的网站,客户说要做活泼一点,怎么搞呀?我设计了一个头部,我发给你看一下啊!指点一下 但是下面我就不知道怎么布局了,现在头都想大了。"

Docusaurus Plushie

答复:这是一个没有系统的考虑整体风格而急于动手的例子。问题主要由以下几个方面:

一、布局上,Banner割裂的边缘限制了视线的拓展,显得死板而不够透气。其他元素也是大框套小框的思路。总体的布局思路没有逃脱条条框框的限制,看得出来,整个设计是思路没有打开却急于动手的表现。要做到活泼的设计,不是用一张现成的关于儿童的图片放上去就行了,要让各个设计元素往这个方向走,比如布局,比如色彩,比如质感,比如插图和图标的使用,比如字体的选择等等,所以你的问题是完全没有系统的思考这些问题,还没找到答案就急于往前走,结果肯定是刚刚开始就感觉无所适从。

二、再来说说具体的问题,Banner的设计在整个页面中通常起到非常重要的作用,是视觉的焦点,先不说你选的这个图片方向对不对,但这个设计看上去过于小气,原因是图片中的元素视觉比重都差不多,没有重点,没有主次。其次是Logo文字以及宣传语文字的设计,感觉太散、太单薄,需要加强他们的视觉比重和气氛感受。Banner底部的弧形边缘不仅没有起到给整个气氛加分的效果,反而割裂了Banner的设计,看上去很不美观。

如何避免盲目开始,途中无从下手的问题出现?很简单,画草图。虽然我个人有时候也会省略画图的步骤,但每次至少会在 PS 中做一个主页的设计方案,其中包括需要放置的信息内容、排版、色彩方案以及设计方案的说明文字。但我发现相较于纸和笔来说,这样的方式还是限制了创意的拓展。所以还是强烈建议大家在开始设计之前画草图。很难想象,不通过画草图的方式能做出来下面的设计效果。

Docusaurus Plushie

但是纸和笔的方式也有它的缺陷就是在设计排版方面不能精确的定位元素的空间。所以为了弥补这个缺憾,建议大家使用960像素网格系统的草图稿纸。纸和笔的自由保证了创意的拓展,而不用纠缠于实现方面的技术问题。

Docusaurus Plushie

另外,在平时的学习积累中,多从整体风格上分析优秀的设计作品是如何考虑和实现的,可以尝试从以下几个方面分析网站风格:一、概念元素:背景、修饰图形等;二、具象元素:文字、照片、插图、图标图形;三、关系元素:方向、位置、空间、重心;四、交互方式:节奏、运动方式;五、色彩方案。

问题四 :设计过程中遇到困难,随意调整设计方向。

画好了草图就要按照设计方向坚决执行,这样才能保证前期的创意阶段的工作不被浪费。很多设计师前期的创意构思都很有想法,但是一旦开始设计,途中遇到寻找素材或者技术方面的困难,或者突然发现某一个素材很不错,很漂亮,马上抛弃前面的整个创意,开辟一条新路从头开始,但往往做到半中间就再做不下去了,导致设计总是半途而废,情绪上不断受挫,焦躁不堪。而我们如果看过高手的设计过程,比如文子的光大银行的设计视频,我们就会发现高手从来都不轻易的改变已经设定好的设计方向,并且总能把我们看来完全用不着的素材变废为宝,从而拥有化腐朽为神奇的本事。千万不要花费大量时间去寻找完美的、拿来不用调整就能用的设计素材,能找到这样的素材的几率比中彩票还要低,而是要不断提高我们将看上去和整体设计毫不搭边的素材融合进整体设计的能力。

问题五 :细节不够讲究,显得粗糙。

一些设计师给我发来个人作品让我给说说建议,我发现其中共同存在的问题就是设计中细节做的不好。渐变和高光过于生硬、阴影的距离和不透明度太高、对齐方式偏差几个像素、上下左右边距距离太小、元素和背景的反差不够造成元素清晰度不够等几个问题是常见的设计毛病。这些问题虽然也涉及到技术的问题,但是最重要的我认为还是用心不够、认为做到差不多就行了的态度问题。这里我向大家推荐Dribbble 和PremiumPixels这两个网站,这其中的设计作品无论哪一个细节都是非常完美、无可挑剔的,下面是我随手从这两个网站中拿出来的作品,大家应该能从这里理解网页设计就是细节的艺术这句话的含义。

Docusaurus Plushie

Docusaurus Plushie

问题六 :技术不过关,创意无法得到实现。

这个无需多说,只有通过大量的设计和练习才能尽可能多的掌握具体的技术。但是无论是跟着网上的设计教程学习,还是通过研究别人的PSD文件也好,不能做过一遍就过去了,拿我之前翻译的《在photoshop中制作一个飘浮于空中的茂盛的"树屋"》这篇文章来说,你需要通过这篇教程掌握的是如何处理手头的素材,将其融合于整体的场景之中的思路的方法,看看高手是如何通过使用调整图层、自由变换、色彩范围选择等一系列的技术去实现需要的效果,而不仅仅是按照教程做一遍就完了,下次遇到需要自己动手创建场景的时候依然无所适从。如果你已经有了一定的PS基础,我建议多练习些图标和场景的创建,例如下面这个精致的西红柿图标和几个场景的创建,认真研究,你一定能学到很多东西。

Docusaurus Plushie

Docusaurus Plushie

Docusaurus Plushie

把矩形放到合适的位置上

· 阅读需 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)
}
}
}

茶叶主题的Logo设计欣赏

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

一、韵沣茶叶

Docusaurus Plushie

二、金茗鼎肆

Docusaurus Plushie

三、瓦舍

Docusaurus Plushie

四、茶仙生

Docusaurus Plushie

五、云里雾里

Docusaurus Plushie

六、茶舍

Docusaurus Plushie

七、听风

Docusaurus Plushie

八、千家寨

Docusaurus Plushie

九、 冬凌仙草

Docusaurus Plushie

十、 如是·素品

Docusaurus Plushie

十一、无杂

Docusaurus Plushie

十二、七尺茶叶

Docusaurus Plushie

十三、本来

Docusaurus Plushie

十四、小的盈满

Docusaurus Plushie

十五、茶百道

Docusaurus Plushie

十六、千茶记

Docusaurus Plushie

十七、藏湘

Docusaurus Plushie

十八、茗田茶饮

Docusaurus Plushie

十九、小茶仙

Docusaurus Plushie

二十、茶诱惑

Docusaurus Plushie

二十一、拾寅

Docusaurus Plushie

二十二、壹茶

Docusaurus Plushie

二十三、道然茶叶

Docusaurus Plushie

二十四、黑苦荞茶

Docusaurus Plushie

二十五、龙归山茶

Docusaurus Plushie

二十六、同泰春

Docusaurus Plushie

二十七、满堂红

Docusaurus Plushie

二十八、茶说

Docusaurus Plushie

二十九、绿歌

Docusaurus Plushie

三十、茶日子

Docusaurus Plushie

三十一、鸣溪茶叶

Docusaurus Plushie

三十二、初寻

Docusaurus Plushie

三十三、山海绿

Docusaurus Plushie

三十四、藏心茶语

Docusaurus Plushie

三十五、达观茶坊

Docusaurus Plushie

三十六、德艺堂

Docusaurus Plushie

三十七、友居茶楼

Docusaurus Plushie

三十八、天醇茶坊

Docusaurus Plushie

三十九、云茶商盟

Docusaurus Plushie

四十、一杯茶

Docusaurus Plushie

四十一、一缕清香

Docusaurus Plushie

四十二、乌沃

Docusaurus Plushie

四十三、一日三茶

Docusaurus Plushie

四十四、清铧铁观音

Docusaurus Plushie

四十五、壹拾叁月

Docusaurus Plushie

四十六、卧云山房

Docusaurus Plushie

四十七、茶末

Docusaurus Plushie

四十八、GreenTea Extreme

Docusaurus Plushie

四十九、TeaCup

Docusaurus Plushie

五十、Kinesiska Tecompagniet

Docusaurus Plushie

五十一、茗人名岩

Docusaurus Plushie

五十二、Tea Lab

Docusaurus Plushie

五十三、TeaPond

Docusaurus Plushie

五十四、Tea Peddler

Docusaurus Plushie

五十五、Swan Tea

Docusaurus Plushie

五十六、DrinkDelight

Docusaurus Plushie

五十七、Kinesiska Tecompagniet

Docusaurus Plushie

五十八、Master Tea

Docusaurus Plushie

五十九、Kimite

Docusaurus Plushie