IOS开发自定义Button的外观和交互行为示例详解

正文通过 Style 改变组件的外观或行为是 SwiftUI 提供的一项非常强大的功能。本文将介绍如何通过创建符合 ButtonStyle 或 Primitiv

正文

通过 Style 改变组件的外观或行为是 SwiftUI 提供的一项非常强大的功能。本文将介绍如何通过创建符合 ButtonStyle 或 PrimitiveButtonStyle 协议的实现,自定义 Button 的外观以及交互行为。

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

定制 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") })

使用 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)

创建一个通用性好 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)
}

使用 PrimitiveButtonStyle 定制交互行为

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

尽管 Button 的默认手势与 TapGestur 单击操作类似,但 Button 的手势是一种不可撤销的操作。而 TapGesture 在不松开手指的情况下,如果移动到可点击区域外,SwiftUI 将不会调用 onEnded 闭包中的操作。

假如,我们想达成与 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()
    }
}

或许有人会说,既然上面的代码可以通过 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)
    }
}

当然,用 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: {})
        }
    }
}

总结

尽管自定义 Style 的效果显著,但遗憾的是,目前 SwiftUI 仅开放了少数的组件样式协议供开发者自定义使用,并且提供的属性也很有限。希望在未来的版本中,SwiftUI 可以为开发者提供更加强大的自定义组件能力。

以上就是IOS开发自定义Button的外观和交互行为示例详解的详细内容,更多关于IOS自定义Button外观交互的资料请关注好代码网其它相关文章!