如何修改Swiftui页面左上角的 < Back 按钮?

在 SwiftUI 中,可以使用 navigationBarBackButtonHidden(_:) 方法来隐藏返回按钮的文字,并在其位置上添加一个新的视图,例如一个图片或自定义的文本。

下面是一个示例代码,将返回按钮的文本修改为“返回”:

struct ContentView: View {
    var body: some View {
        NavigationView {
            Text("Hello World")
                .navigationBarTitle("My App")
                .navigationBarBackButtonHidden(true)
                .navigationBarItems(leading:
                    Button(action: {
                        // Navigate back to the previous view
                    }, label: {
                        HStack {
                            Image(systemName: "chevron.left")
                            Text("返回")
                        }
                    })
                )
        }
    }
}

在这个示例中,我们首先使用 .navigationBarBackButtonHidden(true) 将默认的返回按钮的文本隐藏掉,然后使用 .navigationBarItems(leading:) 在左边的导航栏位置添加一个按钮。这个按钮使用了一个包含图片和自定义文本的水平堆栈(HStack)来模拟默认的返回按钮样式,并将自定义文本设置为“返回”。当用户点击该按钮时,可以执行所需的操作来导航回到上一个视图。

Swiftui中 .padding() 是如何用的它的参数都有那些

在SwiftUI中,.padding()是一种视图修饰符,它可以在视图周围添加填充空间。它可以使用不同类型的参数进行调整。以下是常见的参数:

  1. padding():添加相等的填充值,例如.padding()。
  2. padding(_:):通过传递一个CGFloat类型的值来设置所有边的填充大小,例如.padding(16)。
  3. padding(_:edges:):通过传递一个CGFloat类型的值和一个边缘指定要填充的边缘,例如.padding(16, .top)。
  4. padding(.all, 10):指定所有边的填充大小。
  5. padding(.top, 10):指定顶部边缘的填充大小。
  6. padding(.horizontal, 10):指定水平方向上的填充大小。
  7. padding(.bottom, 10):指定底部边缘的填充大小。

需要注意的是,padding()修饰符可以嵌套使用。这意味着你可以在一个已经有填充值的视图上添加另一个填充修饰符,以增加填充空间。例如,如果你想增加一些额外的填充空间,可以使用.padding().padding(10)。

在SwiftUI中,HStack布局如何让其中的元素两边对齐?

在SwiftUI中,你可以使用Spacer()来实现HStack中的元素两边对齐。

具体来说,你可以将Spacer()放置在需要对齐的元素的前面或后面,以填充空间并将其推到对齐位置。

例如,以下代码片段将在水平方向上创建一个HStack,其中有两个文本元素,并且第一个文本元素位于左侧,第二个文本元素位于右侧:

HStack {
    Text("Left-aligned text")
    Spacer()
    Text("Right-aligned text")
}

在这个例子中,Spacer()被用于第一个和第二个文本元素之间的空间,从而将第一个文本元素推到左侧,将第二个文本元素推到右侧。

在iOS 15 中为SwiftUI 按钮设置样式的方法详解

在iOS 15 中,Apple 引入了一个新方法,来为SwiftUI 和UIKit 框架的iOS App 客制化按钮。这篇教学文章主要会介绍SwiftUI 的新功能,如果你有兴趣了解如何在iOS 15 中设置 UIButton 样式,可以参考Sarun 撰写的这篇文章

在SwiftUI 设置按钮样式

在我们介绍iOS 15 的新修饰符(modifier) 之前,先重温一下现在我们设置按钮样式的方法。

swiftui-button-in-ios-15

比如说,我们想创建一个圆角按钮,就可以如此编写程式码:

Button(action: {}) {
    Text("Buy me a coffee")
}
.padding()
.foregroundColor(.white)
.background(Color.purple)
.clipShape(RoundedRectangle(cornerRadius: 5))

我们利用 .clipShape 修饰符,来客制化按钮的前景和背景颜色、应用间距(padding) 和圆角。

在iOS 15 中,要创建一个类似的圆角按钮,我们可以使用新修饰符buttonBorderShape,并应用 BorderedProminentButtonStyle 新样式:

Button(action: {}) {
    Text("Buy me a coffee")
}
.tint(.purple)
.buttonStyle(.borderedProminent)
.buttonBorderShape(.roundedRectangle(radius: 5))
.controlSize(.large)

应用 .borderedProminent 样式后,iOS 就会将按钮呈现为紫色背景和白色文字。.buttonBorderShape则是让我们设置按钮的边框形状(border shape),在这里,我们会设置为.roundedRectangle,以创建圆角按钮。

控制按钮的大小

我们可以利用 .controlSize 来改变按钮的大小。按钮的预设大小是.regular,其他可选的设定包括.large.small、和.mini。让我们看看不同大小的按钮:

swiftui-buttons-control-size

按钮的边框形状

除了 .roundedRectangle 外,SwiftUI 还提供了另外一个边框形状.capsule,让开发者可以创建胶囊形状的按钮。

swiftui-button-border-shape

我们也可以使用.automatic,让系统自动调整按钮的形状。

改变按钮样式

之前我们一直在使用 .borderProminent 按钮样式。新版本的SwiftUI 提供了其他内置样式,包括.bordered.borderless.plain.bordered样式是我们通常会使用的样式。以下是在浅色(light) 和深色模式(dark mode) 下,使用 .bordered 样式的按钮预览图片:

swiftui-button-style

当然,我们还是可以自己编写程式码来创建同样的按钮,但这个iOS 15 的新样式就可以为我们节省不少编写程式码的时间。

把样式应用到多个按钮

设定好按钮样式之后,我们就可以简单地为一组按钮应用同一样式,看看以下例子:

VStack {
    Button(action: {}) {
        Text("Add to Cart")
            .font(.headline)
    }

    Button(action: {}) {
        Text("Discover")
            .font(.headline)
            .frame(maxWidth: 300)
    }

    Button(action: {}) {
        Text("Check out")
            .font(.headline)
    }
}
.tint(.purple)
.buttonStyle(.bordered)
.controlSize(.large)

利用按钮Role

SwiftUI 框架的iOS 15 版本为 Button 引入了一个新的 role 选项。这个选项会描述按钮的Semantic Role。iOS 会根据指定的role,自动为按钮呈现适当的外观。

比如说,我们把role 定义为.destructive

Button("Delete", role: .destructive) {
    print("Delete")
}
.buttonStyle(.borderedProminent)
.controlSize(.large)

iOS 就会自动以红色显示Delete按钮。让我们看看不同role 和样式的配搭,会创建出怎样的按钮外观:

swiftui-button-role

确认对话框(Confirmation Dialog)

iOS 15 除了新的按钮样式之外,还有一个新修饰符.confirmationDialog,我们可以将其附加到 Button 来显示一个确认对话框。

让我们看看显示确认对话框的范例程式码:

struct DemoView: View {
    @State private var isShowingDialog = false
    var body: some View {
        Button("Delete", role: .destructive) {
            isShowingDialog = true
        }
        .buttonStyle(.borderedProminent)
        .controlSize(.large)
        .confirmationDialog("Are you sure to delete the data?", isPresented: $isShowingDialog, titleVisibility: .visible) {

            Button("Confirm", role: .destructive) {
                // Handle the delete action.
            }
            Button("Cancel", role: .cancel) {

            }
        }
    }
}

我们可以在 .confirmationDialog 修饰符设定标题和布林值,用来决定是否显示对话框。你也可以在 titleVisibility 选择是否显示标题。

编写好上述的程式码后,我们会得到以下的确认对话框:

swiftui-confirmation-dialog

利用Material 客制化按钮

在iOS 15 中,SwiftUI 引入了一个Material 型别,让开发者可以创建不同的模糊效果(blur effect)。我们可以在.background 修饰符添加以下的Material,在一个视图后面的视图上应用模糊效果。

  • .ultraThickMaterial
  • .thickMaterial
  • .regularMaterial
  • .thinMaterial
  • .ultraThinMaterial

让我们看看应用了 .ultraThinMaterial 的范例程式码:

Button(action: {}) {
    Text("Add to Cart")
        .font(.headline)
}
.padding()
.background(.ultraThinMaterial, in: Capsule())

就如Apple 所说,添加Material 就像是在视图与其背景之间插入一个半透明层(translucent layer)。如果我们使用不同的Material,就会实作出不同的模糊效果。你可以参考下图,看看不同Material 实作出的模糊效果。

swiftui-background-material

Toggle 按钮

swiftui-toggle

在iOS 中,Toggle 就像是一个开关按钮。在iOS 15 中,我们可以使用 .toggleStyle 修饰符,将Toggle 设置显示为一个按钮:

struct DemoView: View {
    @State private var isEnabled = false

    var body: some View {

        Toggle(isOn: $isEnabled) {
            Label("Airplane mode", systemImage: "airplane.circle.fill")
        }
        .padding()
        .tint(.purple)
        .controlSize(.large)
        .toggleStyle(.button)
    }
}

把 .toggleStyle 设置为 .button 后,Toggle 就会变成按钮的模样。以下是Toggle 在ON/OFF 状态时的外观:

swiftui-toggle-button-ios-15

总结

iOS 15 为客制化SwiftUI 按钮带来了许多改进。虽然我们也可以创建自己的方法来设计按钮样式,但新版本的SwiftUI 有很多内置样式,减轻了开发者的工作。

这些新功能唯一的缺点,就是只支持iOS 15。如果你的App 需要兼容旧版本的iOS,就需要自己实作来设置按钮样式。

SwiftUI 表单中的 4 种Picker选择器样式

Picker是用于从选项列表中选择值的控件。

Picker在 a 中使用时Form选择器样式会根据平台和版本自动更改。

在本文中,我们将探讨所有可能的选择器样式,以便您可以选择适合您需要的样式。

默认选择器样式

如果您不指定选择器样式,SwiftUI 将根据平台和版本选择一个。

在 iOS 16 中,选择器将使用menu样式。

当用户点击选择器时,该menu样式会将选项显示为弹出菜单。

struct ContentView: View {
@State private var selectedTheme = "Dark"
let themes = ["Dark", "Light", "Automatic"]

var body: some View {
NavigationStack {
Form {
Section {
Picker("Appearance", selection: $selectedTheme) {
ForEach(themes, id: \.self) {
Text($0)
}
}
}
}
.navigationTitle("Display & Brightness")
}
}
}
iOS 16 中的默认选择器样式。
iOS 16 中的默认选择器样式。

在未来的 iOS 版本中,默认样式可能会发生变化。

如果你想使用这种风格,你可以明确指定.pickerStyle(.menu)一个选择器视图。

Picker("Appearance", selection: $selectedTheme) {
ForEach(themes, id: \.self) {
Text($0)
}
}
.pickerStyle(.menu)

如果您有一长串选项并且想要将选项选择放到另一个视图中navigationLink,您可以通过应用选择器样式来实现。

这是您在 Apple 设置应用程序中看到的样式。

struct ContentView: View {
@State private var selectedTheme = "Dark"
let themes = ["Dark", "Light", "Automatic"]

var body: some View {
NavigationStack {
Form {
Picker("Appearance", selection: $selectedTheme) {
ForEach(themes, id: \.self) {
Text($0)
}
}
.pickerStyle(.navigationLink)
}
.navigationTitle("Display & Brightness")
}
}
}

选择器就像一个导航链接,将用户推送到另一个带有选项列表的视图。

.pickerStyle(.navigationLink)
.pickerStyle(.navigationLink)

内联选择器样式

如果您只有几个选择,inline风格可能是一个不错的选择。

它将每个选项与表单中的其他控件放在一起。使用此样式,您只需轻按一下即可选择一个选项。

struct ContentView: View {
@State private var selectedTheme = "Dark"
let themes = ["Dark", "Light", "Automatic"]

var body: some View {
NavigationStack {
Form {
Picker("Appearance", selection: $selectedTheme) {
ForEach(themes, id: \.self) {
Text($0)
}
}
.pickerStyle(.inline)
Toggle("Bold Text", isOn: .constant(true))
}
.navigationTitle("Display & Brightness")
}
}
}

我还添加了一个Toggle控件来演示选择器如何与其他控件一起布局。

.pickerStyle(.内联)
.pickerStyle(.内联)

轮式拾取器样式

滚轮选择器样式将在可滚动的滚轮中显示选项。

此样式还将选项与表单中的其他控件内嵌在一起,但外观类似轮子

下面是轮子的一些行为。

  • 无论选项如何,它都有一个固定的高度。
  • 只有有限数量的选项是可见的
struct ContentView: View {
@State private var selectedTheme = "Dark"
let themes = ["Dark", "Light", "Automatic"]

var body: some View {
NavigationStack {
Form {
Picker("Appearance", selection: $selectedTheme) {
ForEach(themes, id: \.self) {
Text($0)
}
}
.pickerStyle(.wheel)
Toggle("Bold Text", isOn: .constant(true))
}
.navigationTitle("Display & Brightness")
}
}
}
.pickerStyle(.wheel)
.pickerStyle(.wheel)

分段选择器样式

最后一种样式是,其中每个选项都以类似选项卡的样式segmented呈现。

struct ContentView: View {
@State private var selectedTheme = "Dark"
let themes = ["Dark", "Light", "Automatic"]

var body: some View {
NavigationStack {
Form {
Picker("Appearance", selection: $selectedTheme) {
ForEach(themes, id: \.self) {
Text($0)
}
}
.pickerStyle(.segmented)
Toggle("Bold Text", isOn: .constant(true))
}
.navigationTitle("Display & Brightness")
}
}
}

所有选项都在同一行水平对齐,因此它仅适用于只有少数选项的选择器。

.pickerStyle(.segmented)
.pickerStyle(.segmented)

结论

尽管标题说的是 SwiftUI Form 中的选择器样式,但您可以在任何需要的地方使用这些样式。

唯一的区别是外观可能不一样,因为 SwiftUI Form 将自定义样式应用于其子视图。

如何更改 SwiftUI 字体大小

在 SwiftUI 中为文本视图设置字体大小有两种方法。

  • 固定尺寸
  • 动态大小

什么是固定尺寸

无论用户偏好如何,固定字体大小都保持不变

如何设置固定字体大小

要设置固定字体大小,我们在创建字体时指定了我们想要的大小。我们可以为系统字体和自定义字体设置此项。

系统字体

我们使用方法创建系统字体Font.system(size:weight:design:)。这个方法让我们指定一个我们想要的固定字体大小。

下面是一个示例,我们将第二个文本的字体设置为 36 磅的固定大小。

VStack {
Text("Hello, world!")
Text("Hello, world!")
.font(.system(size: 36))
}
默认字体大小与 36 点的固定字体大小。
默认字体大小与 36 点的固定字体大小。

系统字体有多种变体供我们选择。您可能会注意到,该方法中有权设计参数Font.system(size:weight:design:)

您还可以设置粗细和设计以及字体大小。

这是一个针对不同粗细和设计的固定系统字体示例。

VStack {
Text("Hello, world!")
.font(.system(size: 36))
Text("Hello, world!")
.font(.system(size: 36, weight: .semibold))
Text("Hello, world!")
.font(.system(size: 36, weight: .bold, design: .monospaced))
Text("Hello, world!")
.font(.system(size: 36, weight: .heavy, design: .rounded))
Text("Hello, world!")
.font(.system(size: 36, weight: .black, design: .serif))
}
默认样式、半粗体、等宽设计的粗体、圆形设计的粗体和衬线设计的黑色体。
默认样式、半粗体、等宽设计的粗体、圆形设计的粗体和衬线设计的黑色体。

自定义字体

为自定义字体设置固定字体大小与系统字体没有什么不同。唯一不同的是我们改用Font.custom(_:fixedSize:)方法。

下面是一个示例,我们使用固定大小为 36 磅的“American Typewriter”字体。

Text("Hello, world!")
.font(.custom("AmericanTypewriter", fixedSize: 36))
American Typewriter 字体,固定大小为 36 磅。
American Typewriter 字体,固定大小为 36 磅。

请注意,我们在这里使用Font.custom(_:fixedSize:),而不是Font.custom(_:size:)您将在后面的部分中看到不同之处,我们将在其中探索自定义字体的动态大小

自定义字体可以有不同的 weight 和 design,所以我们不能像系统字体那样将其指定为方法参数。我们必须手动指定自定义变体的名称。

这是 American Typewriter 不同字体样式的示例。

VStack {
Text("Hello, world!")
.font(.custom("AmericanTypewriter", fixedSize: 36))
Text("Hello, world!")
.font(.custom("AmericanTypewriter-Semibold", fixedSize: 36))
Text("Hello, world!")
.font(.custom("AmericanTypewriter-Bold", fixedSize: 36))
Text("Hello, world!")
.font(.custom("AmericanTypewriter-Condensed", fixedSize: 36))
Text("Hello, world!")
.font(.custom("AmericanTypewriter-CondensedLight", fixedSize: 36))
}
American Typewriter 有常规、半粗体、粗体、浓缩和浓缩的浅色风格。
American Typewriter 有常规、半粗体、粗体、浓缩和浓缩的浅色风格。

什么是动态尺寸

动态字体大小是我们根据其用法(文本样式)而不是确切的点大小来描述文本大小的一种方式。

Apple 目前有 11 种文本样式。

  • 大标题
  • 标题 1
  • 标题 2
  • 标题 3
  • 标题
  • 身体
  • 大喊
  • 小标题
  • 脚注
  • 标题 1
  • 字幕 2

像这样定义大小的好处是适应性和可访问性。文本大小和粗细会自动响应粗体文本和更大字体等辅助功能。

下面是每个文本样式如何适应动态字体大小的示例。

文本样式根据用户偏好调整其大小。
文本样式根据用户偏好调整其大小。

将此与固定字体大小进行比较,后者始终保持不变。

固定字体大小不会改变。
固定字体大小不会改变。

如何设置动态字体大小

要设置动态字体大小,我们将文本样式或用途指定系统字体

系统字体

要为系统字体设置动态字体大小,我们使用Font.system(_:design:)方法创建它。

如您所见,尺寸和重量参数消失了,因为它们会根据用户偏好进行动态调整。

以下是大型动态字体大小(默认大小)上的五种文本样式的示例。

VStack {
Text("Large Title")
.font(.largeTitle)
Text("Title 1")
.font(.title)
Text("Title 2")
.font(.title2)
Text("Title 3")
.font(.title3)
Text("Body")
.font(.body)
}
大型动态字体大小的大标题、标题 1、标题 2、标题 3 和正文样式。
大型动态字体大小的大标题、标题 1、标题 2、标题 3 和正文样式。

这是 xxxLarge Dynamic Type 尺寸上的相同文本样式。

大标题、标题 1、标题 2、标题 3 和正文样式,采用 xxx 大型动态字体大小。
大标题、标题 1、标题 2、标题 3 和正文样式,采用 xxx 大型动态字体大小。

您可以从 Apple Human Interface Guideline中查看每种文本样式的字体大小和粗细。

自定义字体

动态调整字体外观不是一件容易的事。Apple 为我们做了繁重的工作,为系统字体每种文本样式定义字体大小、粗细和行距,以确保它根据用户偏好可读。

幸运的是,我们可以在自定义字体上使用其中的一些优势(字体缩放)

要使用与Apple 文本样式相同的比例因子制作自定义字体比例Font.custom(_:size:relativeTo:),我们使用方法。

这是一个示例,我使用与 Apple 文本样式相同大小的自定义字体并使用相同的比例因子

VStack {
Text("Large Title")
.font(.custom(
"AmericanTypewriter",
size: 34,
relativeTo: .largeTitle))
Text("Title 1")
.font(.custom(
"AmericanTypewriter",
size: 28,
relativeTo: .title))
Text("Title 2")
.font(.custom(
"AmericanTypewriter",
size: 22,
relativeTo: .title2))
Text("Title 3")
.font(.custom(
"AmericanTypewriter",
size: 20,
relativeTo: .title3))
Text("Body")
.font(.custom(
"AmericanTypewriter",
size: 17,
relativeTo: .body))
}

如您所见,它以与系统文本样式相同的速度缩放。

使用与系统字体具有相同字体大小和字体比例的自定义字体。
使用与系统字体具有相同字体大小和字体比例的自定义字体。

Font.custom( :size:) 与 Font.custom( :size:relativeTo:)

还有另一种你应该知道的自定义字体的方法,Font.custom(_:size:).

该方法等同于Font.custom(_:size:relativeTo:)参数relativeTo设置为.body文本样式

下面是一个使用Font.custom(_:size:)withFont.custom(_:size:relativeTo:)和 的例子Font.custom(_:fixedSize:)

VStack {
Text("Hello, world!")
.font(.custom(
"AmericanTypewriter",
size: 17,
relativeTo: .body))
Text("Hello, world!")
.font(.custom(
"AmericanTypewriter",
size: 17))
Text("Hello, world!")
.font(.custom(
"AmericanTypewriter",
fixedSize: 17))
}

它在大动态字体大小上看起来是一样的。

Font.custom(_:size:)、Font.custom(_:size:relativeTo:) 和 Font.custom(_:fixedSize:) 在大型动态字体大小上。
Font.custom(_:size:)、Font.custom(_:size:relativeTo:) 和 Font.custom(_:fixedSize:) 在大型动态字体大小上。

但是当我们将大小更改为 xxx large 时,Font.custom(_:fixedSize:)保持不变,而其他两个适应相同的比例因子。

Font.custom(_:size:)、Font.custom(_:size:relativeTo:) 和 Font.custom(_:fixedSize:) 在 xxx 大型动态字体大小上。
Font.custom(_:size:)、Font.custom(_:size:relativeTo:) 和 Font.custom(_:fixedSize:) 在 xxx 大型动态字体大小上。

SwiftUI 新功能:利用AsyncImage 非同步加载和显示Remote Image

在WWDC 2021,Apple 为SwiftUI 框架添加了大量新功能,减轻开发者的工作。在iOS 15中,AsyncImage绝对是其中一个值得探讨的新视图。如果你的App 需要从远程伺服器加载和显示图像,有了这个新视图,你就不需要编写自己的程式码来处理非同步(asynchronous) 下载。

AsyncImage是一个内置视图,用于非同步加载和显示Remote Image。我们只需要输入图像URL,AsyncImage就会抓取Remote Image,并将其显示在萤幕上。

注意:请使用Xcode 13 和iOS 15。

AsyncImage 的基本使用

使用AsyncImage 最简单的方法,就是如此指定图像的URL:

AsyncImage(url: URL(string: imageURL))

然后,AsyncImage 就会连接到你提供的URL,并非同步地下载Remote Image。如果图像尚未准备好显示,它会自动将占位符(placeholder) 呈现为灰色。图像完全下载好后,AsyncImage就会以其固有尺寸(intrinsic size) 显示图像。

如果想调整图像的尺寸,我们可以这样传递比例数值(scaling value) 给 scale 参数(parameter):

AsyncImage(url: URL(string: imageURL), scale: 2.0)

比例数值大于1.0 就会缩小图像,相反,比例数值少于1 就会放大图像。

客制化图像尺寸和占位符

AsyncImage也提供了另一个构造函数(constructor),让开发者可以进一步客制化图像:

init<I, P>(url: URL?, scale: CGFloat, content: (Image) -> I, placeholder: () -> P)

我们可以使用上面的 init 初始化AsyncImage,来调整及缩放下载好的图像。更重要的是,我们可以实作自己的占位符。看看以下的范例程式码片段:

AsyncImage(url: URL(string: imageURL)) { image in
    image
        .resizable()
        .scaledToFill()
} placeholder: {
    Color.purple.opacity(0.1)
}
.frame(width: 300, height: 500)
.cornerRadius(20)

在上面的程式码中,AsyncImage提供了下载好的图像。然后,我们应用 resizable() 和 scaledToFill() 修饰符来调整图像尺寸,并把 AsyncImage 视图的尺寸限制为300×500 points。

placeholder参数让我们可以创建自己的占位符,来取代预设的占位符。在以下范例中,我们把占位符设置为浅紫色。

处理非同步操作的不同阶段(Phase)

如果你想更好地控制非同步下载操作,AsyncImage视图就提供了另一个构造函数:

init(url: URL?, scale: CGFloat, transaction: Transaction, content: (AsyncImagePhase) -> Content)

AsyncImagePhase是一个列举(enum),用于追踪下载操作的当前阶段。你可以针对每个阶段提供详细的实作,包括emptyfailuresuccess

看看以下范例程式码片段:

AsyncImage(url: URL(string: imageURL)) { phase in
    switch phase {
    case .empty:
        Color.purple.opacity(0.1)
    case .success(let image):
        image
            .resizable()
            .scaledToFill()
    case .failure(_):
        Image(systemName: "exclamationmark.icloud")
            .resizable()
            .scaledToFit()
    @unknown default:
        Image(systemName: "exclamationmark.icloud")
    }
}
.frame(width: 300, height: 300)
.cornerRadius(20)

Empty的情况下,表示图像未加载,于是我们会显示一个占位符。在success的情况下,我们就会应用几个修饰符,并将图像显示在萤幕上。在failure的情况下,我们就可以在出现错误时提供备用视图(alternate view)。在上面的程式码中,我们就这样显示了一个系统图像。

利用Transaction 来添加动画

同一个 init 可以让我们在阶段更改时指定可选transaction。例如,以下程式码片段在 transaction 参数中指定使用spring 动画:

AsyncImage(url: URL(string: imageURL), transaction: Transaction(animation: .spring())) { phase in
    switch phase {
    case .empty:
        Color.purple.opacity(0.1)

    case .success(let image):
        image
            .resizable()
            .scaledToFill()

    case .failure(_):
        Image(systemName: "exclamationmark.icloud")
            .resizable()
            .scaledToFit()

    @unknown default:
        Image(systemName: "exclamationmark.icloud")
    }
}
.frame(width: 300, height: 500)
.cornerRadius(20)

如此一来,在下载图像后,我们就会看到淡入(fade-in) 动画。我们无法在预览版面中测试程式码,请在模拟器中测试程式码以查看动画。

你也可以把 transition 修饰符附加到 image 视图:

case .success(let image):
    image
        .resizable()
        .scaledToFill()
        .transition(.slide)

如此一来,在显示结果图像时,就会看到滑入(slide-in) 动画。

轻松修复 Xcode 中的“Failed to set plugin placeholders for”

最近使用SwiftUI开发一个小软件,修改Bundle identifier后编译报错提示: Failed to set plugin placeholders for xxxxxxx (无法设置插件占位符)

经过百度google等查找,这个问题不难解决,这里就记录一下具体解决步骤!

第一步

首先关闭xcode,然后删除 ~/Library/Developer/xCode 此目录下的 DerivedData 文件夹。

第二步

然后重新打开xcode。然后单击打开你的项目

第三步

接下来,您需要选择主要目标并转到其build phases选项卡。

第四步

进入build phases选项卡后,您应该会看到一个显示 的下拉菜单Embed App Extensions,单击它。现在您已经打开Embed App Extensions了,

您应该会看到一个复选框,上面写着Copy only when installing

您需要选中该复选框。

最后重新编译即可

SwiftUI用于数据持久性的新属性包装器@AppStorage

前言

在苹果生态的应用中,开发者或多或少都会使用到 UserDefaults。我个人习惯将可被用户自定义的配置信息(精度、单位、色彩等)保存在 UserDefaults 中。随着配置信息的增加,在 SwiftUI 视图中使用的@AppStorage 越来越多。

本文探讨的是如何优雅、高效、安全地在 SwiftUI 中使用@AppStorage,在不借助第三方库的情况下,解决当前@AppStorage 使用中出现的痛点:

  • 支持的数据类型少
  • 声明繁琐
  • 声明容易出现拼写错误
  • 大量@AppStorage 无法统一注入

@AppStorage 基础指南

@AppStorage 是 SwiftUI 框架提供的一个属性包装器,设计初衷是创建一种在视图中保存和读取 UserDefaults 变量的快捷方法。@AppStorage 在视图中的行为同@State 很类似,其值变化时将导致与其依赖的视图无效并进行重新绘制。

@AppStorage 声明时需要指定在 UserDefaults 中保存的键名称(Key)以及默认值。

@AppStorage("username") var name = "zhuangpenglong"

userName为键名称,fatbobman是为username设定的默认值,如果 UserDefaults 中的username已经有值,则使用保存值。

如果不设置默认值,则变量的为可选值类型

@AppStorage("username") var name:String?

默认情况下使用的是 UserDefaults.standard,也可以指定其他的 UserDefaults。

public extension UserDefaults {
    static let shared = UserDefaults(suiteName: "group.com.fatbobman.examples")!
}

@AppStorage("userName",store:UserDefaults.shared) var name = "fat"

对 UserDefaults 操作将直接影响对应的@AppStorage

UserDefaults.standard.set("bob",forKey:"username")

上述代码将更新所有依赖@AppStorage("username")的视图。

UserDefaults 是一种高效且轻量的持久化方案,它有以下不足:

  • 数据不安全

它的数据相对容易提取,所以不要保存和隐私有关的重要数据

  • 持久化时机不确定

为了效率的考量,UserDefaults 中的数据在发生变化时并不会立即持久化,系统会在认为合适的时机才将数据保存在硬盘中。因此,可能发生数据不能完全同步的情况,严重时有数据彻底丢失的可能。尽量不要在其中保存会影响 App 执行完整性的关键数据,在出现数据丢失的状况下,App 仍可根据默认值正常运行

尽管@AppStorage 是作为 UserDefaults 的属性包装器存在的,但@AppStorage 并没有支持全部的property list数据类型,目前仅支持:Bool、Int、Double、String、URL、Data(UserDefaults 支持更多的类型)。

增加@AppStorage 支持的数据类型

除了上述的类型外,@AppStorage 还支持符合RawRepresentable协议且RawValueIntString的数据类型。通过增加RawRepresentable协议的支持,我们可以在@AppStorage 中读取存储原本并不支持的数据类型。

下面的代码添加了对Date类型的支持:

extension Date:RawRepresentable{
    public typealias RawValue = String
    public init?(rawValue: RawValue) {
        guard let data = rawValue.data(using: .utf8),
              let date = try? JSONDecoder().decode(Date.self, from: data) else {
            return nil
        }
        self = date
    }

    public var rawValue: RawValue{
        guard let data = try? JSONEncoder().encode(self),
              let result = String(data:data,encoding: .utf8) else {
            return ""
        }
       return result
    }
}

使用起来和直接支持的类型完全一致:

@AppStorage("date") var date = Date()

下面的代码添加了对Array的支持:

extension Array: RawRepresentable where Element: Codable {
    public init?(rawValue: String) {
        guard let data = rawValue.data(using: .utf8),
              let result = try? JSONDecoder().decode([Element].self, from: data)
        else { return nil }
        self = result
    }

    public var rawValue: String {
        guard let data = try? JSONEncoder().encode(self),
              let result = String(data: data, encoding: .utf8)
        else {
            return "[]"
        }
        return result
    }
}
@AppStorage("selections") var selections = [3,4,5]

对于RawValueIntString的枚举类型,可以直接使用,比如:

enum Options:Int{
    case a,b,c,d
}
@AppStorage("option") var option = Options.a

安全和便捷的声明(一)

@AppStorage 的声明方式有两个令人不悦的地方:

  • 每次都要设定 Key(字符串)
  • 每次都要设定默认值

而且开发者很难享受到代码自动补全和编译时检查带来的快捷、安全的体验。

较好的解决方案是将@AppStorage 集中声明,并在每个视图中通过引用注入。鉴于 SwiftUI 的刷新机制,我们必须要在集中声明、单独注入后仍需保留@AppStorage 的DynamicProperty特征——当 UserDefaults 的值发生变动时刷新视图。

下面的代码能满足以上的要求:

enum Configuration{
    static let name = AppStorage(wrappedValue: "fatbobman", "name")
    static let age = AppStorage(wrappedValue: 12, "age")
}

在视图中使用方法如下:

let name = Configuration.name
var body:some View{
     Text(name.wrappedValue)
     TextField("name",text:name.projectedValue)
}

name和直接在代码中通过@AppStorage 声明的效果类似。不过付出的代价就是需要将wrappedValueprojectedValue明确标注出来。

是否有不标注wrappedValueprojectedValue又能达到上述结果的实现方案呢?在安全和便捷的声明(二)中我们将尝试使用另一种解决途径。

集中注入

在介绍另一种便捷声明方式之前,我们先聊一下集中注入的问题。

【健康笔记 3】目前面临着前言中所描述的情况,配置信息内容很多,如果单独注入会很麻烦。我需要找到一种可以集中声明、一并注入的方式。

安全和便捷的声明(一)中使用的方法对于单独注入的情况是满足的,但如果我们想统一注入的话就需要其他的手段了。

我并不打算将配置数据汇总到一个结构体中并通过支持RawRepresentable协议统一保存。除了数据转换导致的性能损失外,另一个重要问题是,如果出现数据丢失的情况,逐条保存的方式还是可以保护绝大多数的用户设定的。

在基础指南中,我们提到@AppStorage 在视图中的表现同@State 非常类似;不仅如此,@AppStorage 还有一个官方文档从没提到的神奇特质,在 ObservableObject 中具有同@Published 一样的特性——其值发生变化时会触发objectWillChange**。这个特性只发生在@AppStorage 身上,@State、@SceneStorage 都不具备这个能力。

class Defaults: ObservableObject {
    @AppStorage("name") public var name = "fatbobman"
    @AppStorage("age") public var age = 12
}

视图代码:

@StateObject var defaults = Defaults()
...
Text(defaults.name)
TextField("name",text:defaults.$name)

不仅代码整洁了许多,而且由于只需要在Defaults中声明一次,极大的降低了由于字符串拼写错误而出现的不易排查的 Bug。

Defaults中使用的是@AppStorage的声明方式,而Configuration中使用的是AppStorage的原始构造形式。变化的目的是为了能够保证视图更新机制的正常运作。

安全和便捷的声明(二)

集中注入中提供的方法已经基本解决了我在当前使用@AppStorage 中碰到的不便,不过我们还可以尝试另一种优雅、有趣的逐条声明注入的方式。

首先修改一下Defaults的代码

public class Defaults: ObservableObject {
    @AppStorage("name") public var name = "fatbobman"
    @AppStorage("age") public var age = 12
    public static let shared = Defaults()
}

创建一个新的属性包装器Default

@propertyWrapper
public struct Default<T>: DynamicProperty {
    @ObservedObject private var defaults: Defaults
    private let keyPath: ReferenceWritableKeyPath<Defaults, T>
    public init(_ keyPath: ReferenceWritableKeyPath<Defaults, T>, defaults: Defaults = .shared) {
        self.keyPath = keyPath
        self.defaults = defaults
    }

    public var wrappedValue: T {
        get { defaults[keyPath: keyPath] }
        nonmutating set { defaults[keyPath: keyPath] = newValue }
    }

    public var projectedValue: Binding<T> {
        Binding(
            get: { defaults[keyPath: keyPath] },
            set: { value in
                defaults[keyPath: keyPath] = value
            }
        )
    }
}

现在我们可以在视图中采用如下代码来逐个声明注入了:

@Default(\.name) var name
Text(name)
TextField("name",text:$name)

逐个注入且无需标注wrappedValueprojectedValue。由于使用keyPath,避免了可能出现的字符串拼写错误问题。

鱼和熊掌不可兼得,上述的方法还是不十分完美——会出现过度依赖的情况。即使你只在视图中注入了一个 UserDefaults 键值(比如name),但当Defaults中其他未注入的键值内容发生变动时(age发生变化),依赖name的视图也同样会被刷新。

不过由于通常情况下配置数据的变化频率很低,所以并不会对 App 造成什么性能负担。

总结

本文提出了几个在不采用第三方库的情况下,解决@AppStorage 痛点的方案。为了保证视图的刷新机制,分别采用的不同的实现方式。

SwiftUI 中即使一个不起眼的环节也有不少乐趣值的我们探索。

如果想实现完美的逐条注入方式(自动补全、编译器检查、不过度依赖)可以通过创建自己的 UserDefaults 响应代码来实现,这已超出了本文对于@AppStorage 的探讨范围。

SwiftUI 显示网络图片并且缓存到本地磁盘,兼容ios14版本

SwiftUI 显示网络图片并且缓存代码如下:

import Foundation
import SwiftUI

struct ImageWithURL: View {
    
    @ObservedObject var imageLoader: ImageLoaderAndCache

    init(_ url: String) {
        imageLoader = ImageLoaderAndCache(imageURL: url)
    }

    var body: some View {
          Image(uiImage: UIImage(data: self.imageLoader.imageData) ?? UIImage())
              .resizable()
              .clipped()
    }
}

class ImageLoaderAndCache: ObservableObject {
    
    @Published var imageData = Data()
    
    init(imageURL: String) {
        let cache = URLCache.shared
        let request = URLRequest(url: URL(string: imageURL)!, cachePolicy: URLRequest.CachePolicy.returnCacheDataElseLoad, timeoutInterval: 60.0)
        if let data = cache.cachedResponse(for: request)?.data {
            print("got image from cache")
            self.imageData = data
        } else {
            URLSession.shared.dataTask(with: request, completionHandler: { (data, response, error) in
                if let data = data, let response = response {
                let cachedData = CachedURLResponse(response: response, data: data)
                                    cache.storeCachedResponse(cachedData, for: request)
                    DispatchQueue.main.async {
                        print("downloaded from internet")
                        self.imageData = data
                    }
                }
            }).resume()
        }
    }
}

使用方法:

struct ContentView: View {
    var body: some View {
        ImageWithURL("https://www.zhuangpenglong.com/wp-content/uploads/2022/10/aaa.jpeg")
    }
}