SwiftUI:Button styles

Button无疑是swifttui中最受欢迎的元素之一,它也非常特别,因为它是唯一具有两种不同风格协议的组件:ButtonStylePrimitiveButtonStyle
在本文中,让我们探索关于按钮样式的所有知识,以及更多内容。

开始

SwiftUI有三种内置样式:DefaultButtonStyleBorderlessButtonStylePlainButtonStyle
当声明一个简单的按钮时,应用DefaultButtonStyle:

Button("Simple button") { 
  // button tapped
  ...
}
simplebutton.gif

DefaultButtonStyle本身并不是一种风格:它是我们让swiftUI为我们选择风格的方式(基于上下文、平台、父视图等等)。
实际的默认样式是BorderlessButtonStyle,它会在按钮顶部应用蓝色调,如果我们是在iOS14上应用会有一些点击、聚焦等视觉效果。
以下三个声明是等价的:

Button("Simple button") { 
  ...
}

Button("Simple button") { 
  ...
}
.buttonStyle(DefaultButtonStyle())

Button("Simple button") { 
  ...
}
.buttonStyle(BorderlessButtonStyle())

在iOS13中,(蓝色)色调应用于按钮label中声明的图像,因此我们需要添加渲染修饰符(例如:Image("Image").renderingmode (.original))或在图像资产目录中声明正确的渲染。

从ios14只有模板图像将被默认着色。

最后,SwiftUI提供了PlainButtonStyle,它不带颜色地显示按钮标签,但仍然在不同的状态下应用视觉效果:

plainbutton.gif

这些都是swiftUI在iOS中提供给我们的样式:我们可以用ButtonStylePrimitiveButtonStyle创建新的样式,让我们从ButtonStyle开始。

ButtonStyle

文档建议我们在自己声明按钮外观时使用ButtonStyle,但按钮交互的行为与任何其他标准按钮一样(也就是说,它的动作在点击时被触发)。

public protocol ButtonStyle {
  associatedtype Body: View

  func makeBody(configuration: Self.Configuration) -> Self.Body

  typealias Configuration = ButtonStyleConfiguration
}

ButtonStyle的唯一要求是从makeBody(configuration:)返回一个视图,该函数接受一个ButtonStyleConfiguration实例:

public struct ButtonStyleConfiguration {
  public let label: ButtonStyleConfiguration.Label
  public let isPressed: Bool
}

这个配置有两个属性:

  • label是按钮label,例如,如果我们的按钮是Button(action: {}, label: { Text("Hello world") }),那么Text("Hello world")就是我们的label
  • isPressed是按钮的当前状态,可以在ButtonStylemakeBody(configuration:)中用于视觉效果

让我们来定义几个例子:
带有圆角的ButtonStyle:

struct RoundedRectangleButtonStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    HStack {
      Spacer()
      configuration.label.foregroundColor(.black)
      Spacer()
    }
    .padding()
    .background(Color.yellow.cornerRadius(8))
    .scaleEffect(configuration.isPressed ? 0.95 : 1)
  }
}
roundedRectangle.gif

带有文字阴影的ButtonStyle:

struct ShadowButtonStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .shadow(
        color: configuration.isPressed ? Color.blue : Color.black,
        radius: 4, x: 0, y: 5
      )
  }
}
shadow.gif

注意,当点击时,这些新按钮没有默认效果:现在是时候在输出按钮中添加这些效果了。
这就是ButtonStyle的全部内容,它可以让我们自定义任何按钮的外观,主要优点是:

  • 可以将相同的样式应用到多个按钮而不需要重复代码
  • 接受isPressed事件
  • 保持标准的交互/行为

应用和组合多种样式

Button没有接受ButtonStyleConfiguration实例的初始化方法,当组合多个样式时,事情就变得复杂了。
根据我们当前的声明,应用多个ButtonStyles没有效果,只有最接近的样式将被使用(其他样式的makeBody(configuration:)甚至不会被调用):

// 只有RoundedRectangleButtonStyle声效了
Button("Rounded rectangle button style") {
  // button tapped
  ...
}
.buttonStyle(RoundedRectangleButtonStyle())
.buttonStyle(ShadowButtonStyle())
.buttonStyle(BorderlessButtonStyle())
.buttonStyle(DefaultButtonStyle())
roundedRectangle.gif

一个“解决办法”是在ButtonStyle makeBody(configuration:)函数中返回一个新的Button,例如,我们可以如下所示更新RoundedRectangleButtonStyle:

struct RoundedRectangleButtonStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    Button(action: {}, label: {
      HStack {
        Spacer()
        configuration.label.foregroundColor(.black)
        Spacer()
      }
    })
    // 使所有的轻击转到原来的按钮
    .allowsHitTesting(false)
    .padding()
    .background(Color.yellow.cornerRadius(8))
    .scaleEffect(configuration.isPressed ? 0.95 : 1)
  }
}

有了这个新的定义,前面的例子就可以工作了:

Button("Rounded rectangle + shadow button style") {
  // button tapped
  ...
}
.buttonStyle(RoundedRectangleButtonStyle())
.buttonStyle(ShadowButtonStyle())
roundedShadow.gif

这里有个缺点是,样式仅可应用于虚构的、不可点击的按钮,因此不接收任何isPressed事件。

至少就目前而言,解决这些限制的一个简单方法是创建并使用一种新的风格,它可以结合所需的效果,例如:

struct RoundedRectangleWithShadowedLabelButtonStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    HStack {
      Spacer()
      configuration.label.foregroundColor(.black)
        .shadow(
          color: configuration.isPressed ? Color.red : Color.black,
          radius: 4, x: 0, y: 5
        )
      Spacer()
    }
    .padding()
    .background(Color.yellow.cornerRadius(8))
    .scaleEffect(configuration.isPressed ? 0.95 : 1)
  }
}

我们可以这样使用:

Button("Rounded rectangle + shadow button style") {
  // button tapped
  ...  
}
.buttonStyle(RoundedRectangleWithShadowedLabelButtonStyle())
roundedShadow2.gif

PrimitiveButtonStyle

ButtonStyle是关于自定义外观和保持标准交互行为的,而PrimitiveButtonStyle则允许我们自定义两者,这意味着由我们来定义按钮外观,并决定何时以及如何触发按钮动作。
PrimitiveButtonStyle的定义几乎与ButtonStyle相同:

public protocol PrimitiveButtonStyle {
    associatedtype Body : View

    func makeBody(configuration: Self.Configuration) -> Self.Body

    typealias Configuration = PrimitiveButtonStyleConfiguration
}

唯一的区别在于makeBody(configuration:)参数,它现在是PrimitiveButtonStyleConfiguration类型:

public struct PrimitiveButtonStyleConfiguration {
  public let label: PrimitiveButtonStyleConfiguration.Label
  public func trigger()
}

这个配置同样带有buttonlabel属性,但是isPressed现在被trigger()函数所取代:

调用trigger()是我们调用按钮操作的方式,现在由我们来定义正确的时间。
例如,如果我们希望一个按钮只在双击时触发,我们可以定义以下样式:

struct DoubleTapOnlyStyle: PrimitiveButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .onTapGesture(count: 2, perform: configuration.trigger)
  }
}

我们可以像使用其他样式一样使用:

Button("Double tap me") {
  // button double tapped
  ...  
}
.buttonStyle(DoubleTapOnlyStyle())

应用和组合多种基本样式

ButtonStyleConfiguration不同,Button有一个接受PrimitiveButtonStyleConfiguration实例的初始化器,允许我们在同一个按钮上组合/应用多个基本样式。
例如,考虑以下样式:

// 双击按钮动作就会触发 
struct DoubleTapStyle: PrimitiveButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    Button(configuration) // <- Button instead of configuration.label
      .onTapGesture(count: 2, perform: configuration.trigger)
  }
}

// 点击时触发(即使在按钮外终止)
struct SwipeButtonStyle: PrimitiveButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    Button(configuration)
      .gesture(
        DragGesture()
          .onEnded { _ in
            configuration.trigger()
          }
      )
  }
}

当每种样式返回一个按钮时,它们可以组合并一起工作,没有问题:

Button(
  "Double tap or swipe", 
  action: { 
    // handle action here
    ...
  }
)
.buttonStyle(DoubleTapStyle())
.buttonStyle(SwipeButtonStyle())

这种方法有一个小小的副作用:Button(configuration)带有默认的按钮交互和样式,幸运的是我们可以通过定义另一种“plain”样式来删除这两者。

struct PlainNoTapStyle: PrimitiveButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    Button(configuration)
      .buttonStyle(PlainButtonStyle()) // 移除默认样式
      .allowsHitTesting(false)         // 取消事件触发
      .contentShape(Rectangle())       // 替换样式及交互
  }
}

如果我们现在把这个样式添加到我们的按钮定义中,我们只需要双击和滑动就可以真正地使它工作:

Button(
  "Double tap or swipe", 
  action: {
    // handle action here
    ...
  }
)
.buttonStyle(DoubleTapStyle())
.buttonStyle(SwipeButtonStyle())
.buttonStyle(PlainNoTapStyle())
gest.gif

然而,我们可能希望大多数按钮都能保持单点默认交互。

使用PrimitiveButtonStyle和ButtonStyle

我们已经介绍了如何将每个ButtonStyle对以前样式的覆盖,而PrimitiveButtonStyle允许我们组合多个样式(在正确定义的情况下),那么将这两种样式结合起来呢?

我们可以同时应用ButtonStyle和一个或多个PrimitiveButtonStyle,例如:

Button(
  "Primitive + button style", 
  action: { 
    // handle action here
    ...
  }
)
// 即使把手指从按钮上拖出来也能触发按钮事件
.buttonStyle(SwipeButtonStyle()) 
.buttonStyle(RoundedRectangleButtonStyle())
combo.gif

在这些情况下,最后声明ButtonStyle(上面提到的RoundedRectangleButtonStyle)是很重要的,否则它也会删除原始ButtonStyle

我们的ButtonStyle将只接收标准点击手势上的isPressed事件,由于PrimitiveButtonStyle它不知道按钮动作何时被触发。我们有责任在需要的时候为这些样式定义任何视觉效果。

总结

Button是SwiftUI组件,具有最简单的交互:点击按钮触发它们。
在这篇文章中,我们已经看到了如何将任何按钮变成具有完全不同外观和手势的更高级元素:大多数时候我们不需要超越自定义ButtonStyle,但是在需要的时候知道有更强大的工具总是好的。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 227,428评论 6 531
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 98,024评论 3 413
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 175,285评论 0 373
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 62,548评论 1 307
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 71,328评论 6 404
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 54,878评论 1 321
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 42,971评论 3 439
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 42,098评论 0 286
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 48,616评论 1 331
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 40,554评论 3 354
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 42,725评论 1 369
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 38,243评论 5 355
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 43,971评论 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 34,361评论 0 25
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 35,613评论 1 280
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 51,339评论 3 390
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 47,695评论 2 370

推荐阅读更多精彩内容