经过 Style 改动组件的外观或行为是 SwiftUI 供给的一项十分强大的功能。本文将介绍怎么经过创立符合 ButtonStyle 或 PrimitiveButtonStyle 协议的完结,自定义 Button 的外观以及交互行为。

可在 此处 获取本文的典范代码

原文宣布在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】

定制 Button 的外观

按钮是 UI 设计中经常会运用到的组件。相较于 UIKit ,SwiftUI 经过 Button 视图,让开发者以少量的代码便可完结按钮的创立工作。

Button(action: signIn) {
    Text("Sign In")
}

大都情况下,开发者经过为 Button 的 label 参数供给不同的视图来定制按钮的外观。

struct RoundedAndShadowButton<V>:View where V:View {
    let label:V
    let action: () -> Void
    init(label: V, action: @escaping () -> Void) {
        self.label = label
        self.action = action
    }
    var body: some View {
        Button {
            action()
        } label: {
            label
                .foregroundColor(.white)
                .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
                .background(
                    RoundedRectangle(cornerRadius: 10)
                        .foregroundColor(.blue)
                    )
                .compositingGroup()
                .shadow(radius: 5,x:0,y:3)
                .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
    }
}
let label = Label("Press Me", systemImage: "digitalcrown.horizontal.press.fill")
RoundedAndShadowButton(label: label, action: { pressAction("button view") })

自定义 Button 的外观和交互行为

运用 ButtonStyle 定制交互动画

遗憾的是,上面的代码无法修改按钮在点击后的按压作用。幸好,SwiftUI 供给了 ButtonStyle 协议可以帮助咱们定制交互动画。

public protocol ButtonStyle {
    @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body
    typealias Configuration = ButtonStyleConfiguration
}
public struct ButtonStyleConfiguration {
    public let role: ButtonRole?
    public let label: ButtonStyleConfiguration.Label
    public let isPressed: Bool
}

ButtonStyle 协议的运用方式与 ViewModifier 十分相似。经过 ButtonStyleConfiguration 供给的信息,开发者只需完结 makeBody 办法,即可完结交互动画的定制工作。

  • label:目标按钮的当前视图,一般对应着 Button 视图中的 label 参数内容
  • role:iOS 15 后新增的参数,用于标识按钮的角色( 吊销或具备破坏性)
  • isPressed:当前按钮的按压状况,该信息是大都人运用 ButtonStyle 的原动力
struct RoundedAndShadowButtonStyle:ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .foregroundColor(.white)
            .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
            .background(
                RoundedRectangle(cornerRadius: 10)
                    .foregroundColor(.blue)
                )
            .compositingGroup()
        	// 根据 isPressing 来调整交互动画
            .shadow(radius:configuration.isPressed ? 0 : 5,x:0,y: configuration.isPressed ? 0 :3)
            .scaleEffect(configuration.isPressed ? 0.95 : 1)
            .animation(.spring(), value: configuration.isPressed)
    }
}
// 方便引证
extension ButtonStyle where Self == RoundedAndShadowButtonStyle {
    static var roundedAndShadow:RoundedAndShadowButtonStyle {
        RoundedAndShadowButtonStyle()
    }
}

经过 buttonStyle 修饰器应用于 Button 视图

Button(action: { pressAction("rounded and shadow") }, label: { label })
       .buttonStyle(.roundedAndShadow)

自定义 Button 的外观和交互行为

创立一个通用性好 ButtonStyle 完结需求考虑许多条件,例如:role、controlSize、动态字体尺寸、色彩模式等等方面。同 ViewModifier 一样,可以经过环境值获取更多信息:

struct RoundedAndShadowProButtonStyle:ButtonStyle {
    @Environment(\.controlSize) var controlSize
    func makeBody(configuration: Configuration) -> some View {
            configuration.label
                .foregroundColor(.white)
                .padding(getPadding())
                .font(getFontSize())
                .background(
                    RoundedRectangle(cornerRadius: 10)
                        .foregroundColor( configuration.role == .destructive ? .red : .blue)
                )
                .compositingGroup()
                .overlay(
                    VStack {
                        if configuration.isPressed {
                            RoundedRectangle(cornerRadius: 10)
                                .fill(Color.white.opacity(0.5))
                                .blendMode(.hue)
                        }
                    }
                    )
                .shadow(radius:configuration.isPressed ? 0 : 5,x:0,y: configuration.isPressed ? 0 :3)
                .scaleEffect(configuration.isPressed ? 0.95 : 1)
                .animation(.spring(), value: configuration.isPressed)
    }
    func getPadding() -> EdgeInsets {
        let unit:CGFloat = 4
        switch controlSize {
            case .regular:
                return EdgeInsets(top: unit * 2, leading: unit * 4, bottom: unit * 2, trailing: unit * 4)
            case .large:
                return EdgeInsets(top: unit * 3, leading: unit * 5, bottom: unit * 3, trailing: unit * 5)
            case .mini:
                return EdgeInsets(top: unit / 2, leading: unit * 2, bottom: unit/2, trailing: unit * 2)
            case .small:
                return EdgeInsets(top: unit, leading: unit * 3, bottom: unit, trailing: unit * 3)
            @unknown default:
                fatalError()
        }
    }
    func getFontSize() -> Font {
        switch controlSize {
            case .regular:
                return .body
            case .large:
                return .title3
            case .small:
                return .callout
            case .mini:
                return .caption2
            @unknown default:
                fatalError()
        }
    }
}
extension ButtonStyle where Self == RoundedAndShadowProButtonStyle {
    static var roundedAndShadowPro:RoundedAndShadowProButtonStyle {
        RoundedAndShadowProButtonStyle()
    }
}
// 运用
HStack {
    Button(role: .destructive, action: { pressAction("rounded and shadow pro") }, label: { label })
        .buttonStyle(.roundedAndShadowPro)
        .controlSize(.large)
    Button(action: { pressAction("rounded and shadow pro") }, label: { label })
        .buttonStyle(.roundedAndShadowPro)
        .controlSize(.small)
}

image-20230215183940567

运用 PrimitiveButtonStyle 定制交互行为

在 SwiftUI 中,Button 默许的交互行为是在松开按钮的一起执行 Button 指定的操作。而且,在点击按钮后,只要手指( 鼠标 )不松开,不管移动到哪里( 移动到 Button 视图之外 ),松开后仍会执行指定操作。

虽然 Button 的默许手势与 TapGestur 单击操作相似,但 Button 的手势是一种不行吊销的操作。而 TapGesture 在不松开手指的情况下,假如移动到可点击区域外,SwiftUI 将不会调用 onEnded 闭包中的操作。

经网友 @Yoo_Das 的反馈,上文中 “Button 的手势是一种不行吊销的操作” 的描绘不行准确。Button 的手势可以被视为有条件的可吊销操作。在按下按钮后,当手指移动的距离超出了体系预设的距离余量( 没有明确值 )后再松开,按钮闭包并不会被调用。

假如,咱们想达成与 TapGesture 相似的作用( 可吊销按钮 ),则可以经过 SwiftUI 供给的另一个协议 PrimitiveButtonStyle 来完结。

public protocol PrimitiveButtonStyle {
    @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body
    typealias Configuration = PrimitiveButtonStyleConfiguration
}
public struct PrimitiveButtonStyleConfiguration {
    public let role: ButtonRole?
    public let label: PrimitiveButtonStyleConfiguration.Label
    public func trigger()
}

PrimitiveButtonStyle 与 ButtonStyle 两者之间最大的不同是,PrimitiveButtonStyle 要求开发者必须经过自行完结交互操作逻辑,并在适当的时机调用 trigger 办法( 可以理解为 Button 的 action 参数对应的闭包 )。

struct CancellableButtonStyle:PrimitiveButtonStyle {
    @GestureState var isPressing = false
    func makeBody(configuration: Configuration) -> some View {
        let drag = DragGesture(minimumDistance: 0)
            .updating($isPressing, body: {_,pressing,_ in
                if !pressing { pressing = true}
            })
        configuration.label
            .foregroundColor(.white)
            .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
            .background(
                RoundedRectangle(cornerRadius: 10)
                    .foregroundColor( configuration.role == .destructive ? .red : .blue)
            )
            .compositingGroup()
            .shadow(radius:isPressing ? 0 : 5,x:0,y: isPressing ? 0 :3)
            .scaleEffect(isPressing ? 0.95 : 1)
            .animation(.spring(), value: isPressing)
            // 获取点击状况
            .gesture(drag)
            .simultaneousGesture(TapGesture().onEnded{
                configuration.trigger() // 执行 Button 指定的操作
            })
    }
}
extension PrimitiveButtonStyle where Self == CancellableButtonStyle {
    static var cancellable:CancellableButtonStyle {
        CancellableButtonStyle()
    }
}

自定义 Button 的外观和交互行为

或许有人会说,已然上面的代码可以经过 DragGesture 模仿获取到点击状况,那么完全可以不运用 PrimitiveButtonStyle 完结同样的作用。如此一来运用 Style 的优势在哪里呢

  • ButtonStyle 和 PrimitiveButtonStyle 是专门针对按钮的款式 API ,它们不只可以应用于 Button 视图,也可以应用于许多 SwiftUI 预置的体系按钮功能之上,例如:EditButton、Share、Link、NavigationLink( 不在 List 中) 等。
  • keyboardShortcut 修饰器也只能应用于 Button,视图 + TapGesture 无法设定方便键。

不管是双击、长按、乃至经过体感触发,开发者均可以经过 PrimitiveButtonStyle 协议定制自己的按钮交互逻辑。

体系预置的 Style

从 iOS 15 开始,SwiftUI 在原有 PlainButtonStyle、DefaultButtonStyle 的基础上,供给了更加丰厚的预置 Style。

  • PlainButtonStyle:不对 Button 视图增加任何修饰
  • BorderlessButtonStyle:大都情况下的默许款式,在未指定文字色彩的情况下,将文字修改为强调色
  • BorderedButtonStyle:为按钮增加圆角矩形布景,运用 tint 色彩作为布景色
  • BorderedProminentButtonStyle:为按钮增加圆角矩形布景,布景色彩为体系强调色

其中,PlainButtonStyle 除了可以应用于 Button 外,一起也会对 List 以及 Form 的单元格行为形成影响。默许情况下,即使单元格的视图中包含了多个按钮,SwiftUI 也只会将 List 的单元格视作一个按钮( 点击后一起调用一切按钮的操作 )。经过为 List 设置 PlainButtonStyle 风格,便可以调整这一行为,让一个单元格中的多个按钮可以被别离触发。

List {
    HStack {
        Button("11"){print("1")}
        Button("22"){print("2")}
    }
}
.buttonStyle(.plain)

注意事项

  • 同 ViewModifier 不同,ButtonStyle 并不支撑串联,Button 只会选用最靠近的 Style
VStack {
    Button("11"){print("1")} // plain
    Button("22"){print("2")} // borderless
        .buttonStyle(.borderless)
    Button("33"){print("3")} // borderedProminent
        .buttonStyle(.borderedProminent)
        .buttonStyle(.borderless)
}
.buttonStyle(.plain)
  • 某些按钮款式在不同的上下文中的行为和外观会有较大差别,乃至不起作用。例如:无法为 List 中的 NavigationLink 设置款式
  • 在 Button 的 label 视图或 ButtonStyle 完结中增加的手势操作( 例如 TapGesture )将导致 Button 不再调用其指定的闭包操作,附加手势需在 Button 之外增加( 例如下文的 simultaneousGesture 完结 )

为按钮增加 Trigger

在 SwiftUI 中,为了判断某个按钮是否被按下( 尤其是体系按钮 ),咱们一般会经过设置并行手势来增加 trigger :

EditButton()
    .buttonStyle(.roundedAndShadowPro)
    .simultaneousGesture(TapGesture().onEnded{ print("pressed")}) // 设置并行手势
    .withTitle("edit button with simultaneous trigger")

不过,上述办法在 macOS 下不起作用 。经过 Style ,咱们可以在设置按钮款式时为其增加触发器:

struct TriggerActionStyle:ButtonStyle {
    let trigger:() -> Void
    init(trigger: @escaping () -> Void) {
        self.trigger = trigger
    }
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .foregroundColor(.white)
            .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
            .background(
                RoundedRectangle(cornerRadius: 10)
                    .foregroundColor(.blue)
                )
            .compositingGroup()
            .shadow(radius:configuration.isPressed ? 0 : 5,x:0,y: configuration.isPressed ? 0 :3)
            .scaleEffect(configuration.isPressed ? 0.95 : 1)
            .animation(.spring(), value: configuration.isPressed)
            .onChange(of: configuration.isPressed){ isPressed in
                if !isPressed {
                    trigger()
                }
            }
    }
}
extension ButtonStyle where Self == TriggerActionStyle {
    static func triggerAction(trigger perform:@escaping () -> Void) -> TriggerActionStyle {
        .init(trigger: perform)
    }
}

自定义 Button 的外观和交互行为

当然,用 PrimitiveButtonStyle 也一样可以完结:

struct TriggerButton2: PrimitiveButtonStyle {
    var trigger: () -> Void
    func makeBody(configuration: PrimitiveButtonStyle.Configuration) -> some View {
        MyButton(trigger: trigger, configuration: configuration)
    }
    struct MyButton: View {
        @State private var pressed = false
        var trigger: () -> Void
        let configuration: PrimitiveButtonStyle.Configuration
        var body: some View {
            return configuration.label
                .foregroundColor(.white)
                .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
                .background(
                    RoundedRectangle(cornerRadius: 10)
                        .foregroundColor(.blue)
                )
                .compositingGroup()
                .shadow(radius: pressed ? 0 : 5, x: 0, y: pressed ? 0 : 3)
                .scaleEffect(pressed ? 0.95 : 1)
                .animation(.spring(), value: pressed)
                .onLongPressGesture(minimumDuration: 2.5, maximumDistance: .infinity, pressing: { pressing in
                    withAnimation(.easeInOut(duration: 0.3)) {
                        self.pressed = pressing
                    }
                    if pressing {
                        configuration.trigger() // 原来的 action
                        trigger() // 新增的 action
                    } else {
                        print("release")
                    }
                }, perform: {})
        }
    }
}

自定义 Button 的外观和交互行为

总结

虽然自定义 Style 的作用显著,但遗憾的是,目前 SwiftUI 仅开放了少量的组件款式协议供开发者自定义运用,而且供给的特点也很有限。希望在未来的版别中,SwiftUI 可以为开发者供给更加强大的自定义组件能力。

希望本文可以对你有所帮助。一起也欢迎你经过 Twitter、 Discord 频道 或博客的留言板与我进行交流。

订阅下方的 邮件列表,可以及时取得每周的 Tips 汇总。

原文宣布在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】