学习使用SwiftUI开发MacOS应用 - 第4节 单选框与单选组的实现美化及流式布局

在SwiftUI 中 是没有单选框的这个视图控件的,要实现单选框 单选组需要借助 Picker 视图来实现,它会使用系统默认的风格,这可能不是我们想要的效果,为了制作漂亮的单选框我们需要自己动手来实现相应的功能。
首先 我们先看看系统自带的效果:

代码如下:

struct ContentView: View {
    @State var selection = 0
    var body: some View {
        VStack{
            Text("Hello, World!").frame(maxWidth: .infinity, maxHeight: 100)
            HStack {
            Picker(selection: $selection, label: Text("标题:")) {
                    //每个选项
                    Text("第1个选项").tag(1).frame(minWidth: 80)
                    Text("第2个选项").tag(2).frame(minWidth: 80)
                    Text("第3个选项").tag(3).frame(minWidth: 80)
                    Text("第4个选项").tag(4).frame(minWidth: 80)
                    Text("第5个选项").tag(5).frame(minWidth: 80)
                }
            }.pickerStyle(RadioGroupPickerStyle())
            Spacer().frame(maxWidth: .infinity, minHeight: 100)
        }
    }
}

系统默认的是 纵向排列的,如果需要横向排列 则可以使用 

struct ContentView: View {
    @State var selection = 0
    var body: some View {
        VStack {
            Text("Hello, World!").frame(maxWidth: .infinity, maxHeight: 100)

            Picker(selection: $selection, label: Text("标题:")) {
                //每个选项
                Text("第1个选项").tag(1).frame(minWidth: 80)
                Text("第2个选项").tag(2).frame(minWidth: 80)
                Text("第3个选项").tag(3).frame(minWidth: 80)
                Text("第4个选项").tag(4).frame(minWidth: 80)
                Text("第5个选项").tag(5).frame(minWidth: 80)
                Text("第6个选项").tag(6).frame(minWidth: 80)
                Text("第7个选项").tag(7).frame(minWidth: 80)
                Text("第8个选项").tag(8).frame(minWidth: 80)
            } .pickerStyle(RadioGroupPickerStyle())
                    //使之横向排列
                    .horizontalRadioGroupLayout()
            Spacer().frame(maxWidth: .infinity, minHeight: 100)
        }
    }
}

得到的效果如下


真头大,这个效果也不是我们希望的效果,我们应该希望它如果过多就自动换行:
目前为止我们只能自己让它排两行:

struct ContentView: View {
    @State var selection = 0
    var body: some View {
        VStack{
            Text("Hello, World!").frame(maxWidth: .infinity, maxHeight: 100)
            VStack {
                HStack{
                    Text("标题:")
                    VStack{
                        Picker("", selection: $selection) {
                            //每个选项
                            Text("第1个选项").tag(1).frame(minWidth: 80)
                            Text("第2个选项").tag(2).frame(minWidth: 80)
                            Text("第3个选项").tag(3).frame(minWidth: 80)
                            Text("第4个选项").tag(4).frame(minWidth: 80)
                        }
                        Picker("", selection: $selection) {
                            //每个选项
                            Text("第5个选项").tag(5).frame(minWidth: 80)
                            Text("第6个选项").tag(6).frame(minWidth: 80)
                            Text("第7个选项").tag(7).frame(minWidth: 80)
                            Text("第8个选项").tag(8).frame(minWidth: 80)
                        }
                    }.pickerStyle(RadioGroupPickerStyle())
                            .horizontalRadioGroupLayout()
                    Spacer()
                }
            }
            Spacer().frame(maxWidth: .infinity, minHeight: 100)
        }
    }
}

最后效果:


接着 我们要讨论的是如何 制作自己想要的 RadioButton 
在开发项目的时候,可能设计师给你的按钮不是默认系统的风格,这会和设计师给你的风格格格不入,是时候需要我们制作 RadioButton 了。
一个  RadioButton  可以理解为 一个按钮 和 一个文本框,按钮上的效果我们需要自己绘制,或者 使用图片来填充,基于此逻辑,我们开始来制作我们的 RadioButton。
参考 Button 的定义,我们在实现相应的功能,我们需要 RadioButton 可以在右侧装入文本Text视图.

FRadioButton.swfit 页面代码:

import SwiftUI

struct FRadioButton<Label>: View where Label: View {
    let label: Label  //右侧的视图区域
    let action: () -> Void  //定义点击事件
    var checked: Binding<Bool> //绑定的值
 
    public init(checked: Binding<Bool>,action callback: @escaping () -> Void = {}, @ViewBuilder label: () -> Label) {
        self.label = label()
        self.action = callback
        self.checked = checked
    }
    
    var body: some View {
        HStack {
            Button(action: {
                //这里还需要处理一下事件,点击按钮本身外面的HStack.onTapGesture 没有响应
                self.checked.wrappedValue = !self.checked.wrappedValue
                self.action()
            }) {
                Group {
                    //如果选中
                    if (checked.wrappedValue) {
                        ZStack {
                            //绘制选中的效果,你也可以使用图片
                            Circle()
                                    .fill(Color.white)
                                    .frame(width: 20, height: 20)
                                    .overlay(Circle().stroke(Color.gray, lineWidth: 1))
                            Circle()
                                    .fill(Color.blue)
                                    .frame(width: 8, height: 8)
                        }
                    } else {
                        //绘制未选中的效果
                        Circle()
                                .fill(Color.white)
                                .frame(width: 20, height: 20)
                                .overlay(Circle().stroke(Color.gray, lineWidth: 1))
                    }
                }
            }
                    //去掉按钮的边框
                    .buttonStyle(PlainButtonStyle())
            label
            Spacer()
        }.frame(minWidth:30,minHeight: 20)
                //用一个近似透明的背景来确定可点击的范围
                .background(Color.white.opacity(0.0001))
                .onTapGesture {
                    //点击事件处理,和按钮中的一致
                    self.checked.wrappedValue = !self.checked.wrappedValue
                    self.action()
                }
    }
}
//另外两种构造方式
extension FRadioButton where Label == Text {
    internal init<S>(_ title: S, checked: Binding<Bool>,action callback: @escaping () -> Void = {
    }) where S: StringProtocol {
        self.init(checked: checked, action: callback, label: {
            Text(title)
        })
    }
    internal init(_ titleKey: LocalizedStringKey, checked:Binding<Bool>,action callback: @escaping () -> Void = {
    }) {
        self.init(checked: checked, action: callback, label: {
            Text(titleKey)
        })
    }
}

这里 外面的单个点击按钮已经可以使用了,另外 这里我额外的补充一个鼠标经过手势的实现效果。

 

struct ContentView: View {
    @State var selection = 0
    @State var check = false
    var body: some View {
        VStack {
            Text("Hello, World!").frame(maxWidth: .infinity, maxHeight: 100)
            VStack {
                HStack {
                    Text("标题:")
                    VStack {
                        Picker("", selection: $selection) {
                            //每个选项
                            Text("第1个选项").tag(1).frame(minWidth: 80)
                            Text("第2个选项").tag(2).frame(minWidth: 80)
                            Text("第3个选项").tag(3).frame(minWidth: 80)
                            Text("第4个选项").tag(4).frame(minWidth: 80)
                        }
                        Picker("", selection: $selection) {
                            //每个选项
                            Text("第5个选项").tag(5).frame(minWidth: 80)
                            Text("第6个选项").tag(6).frame(minWidth: 80)
                            Text("第7个选项").tag(7).frame(minWidth: 80)
                            Text("第8个选项").tag(8).frame(minWidth: 80)
                        }
                    }.pickerStyle(RadioGroupPickerStyle())
                            .horizontalRadioGroupLayout()
                    Spacer()
                }
            }

            FRadioButton("测试按钮", checked: $check) {
                //配合下面鼠标悬停 显示手势
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
                    NSCursor.pop()
                    NSCursor.pointingHand.push()
                }
            }
                    //添加鼠标手势,不过这里有一个问题,就是 当点击后 不知道什么原因 会被重置 所以 就有了 上面点击0.01秒后再重新设置
                    .onHover { hover in
                        if hover {
                            NSCursor.pointingHand.push()
                        } else {
                            NSCursor.pop()
                        }
                    }
                    .frame(width: 100).foregroundColor(Color.red)


            Spacer().frame(maxWidth: .infinity, minHeight: 100)
        }
    }
}

效果如下:

接着我们来写 RadioGroup 也就是把我们的按钮归组处理,我们需要先给我们使用的数据定义个模型
 

struct RadioModel {
    let value: String
    let text: String
    init(_ v: String, _ t: String? = nil) {
        value = v
        if (t == nil) {
            text = v
        } else {
            text = t!
        }
    }
}

这样我们就可以传入多个模型数组进行绘制分组数据了。最终代码如下:

import SwiftUI

struct FRadioButton<Label>: View where Label: View {
    let label: Label  //右侧的视图区域
    let action: () -> Void  //定义点击事件
    var checked: Binding<Bool> //绑定的值

    public init(checked: Binding<Bool>,action callback: @escaping () -> Void = {}, @ViewBuilder label: () -> Label) {
        self.label = label()
        self.action = callback
        self.checked = checked
    }

    var body: some View {
        HStack {
            Button(action: {
                //这里还需要处理一下事件,点击按钮本身外面的HStack.onTapGesture 没有响应
                self.checked.wrappedValue = !self.checked.wrappedValue
                self.action()
            }) {
                Group {
                    //如果选中
                    if (checked.wrappedValue) {
                        ZStack {
                            //绘制选中的效果,你也可以使用图片
                            Circle()
                                    .fill(Color.white)
                                    .frame(width: 20, height: 20)
                                    .overlay(Circle().stroke(Color.gray, lineWidth: 1))
                            Circle()
                                    .fill(Color.blue)
                                    .frame(width: 8, height: 8)
                        }
                    } else {
                        //绘制未选中的效果
                        Circle()
                                .fill(Color.white)
                                .frame(width: 20, height: 20)
                                .overlay(Circle().stroke(Color.gray, lineWidth: 1))
                    }
                }
            }
                    //去掉按钮的边框
                    .buttonStyle(PlainButtonStyle())
            label
            Spacer()
        }.frame(minWidth:30,minHeight: 20)
                //用一个近似透明的背景来确定可点击的范围
                .background(Color.white.opacity(0.0001))
                .onTapGesture {
                    //点击事件处理,和按钮中的一致
                    self.checked.wrappedValue = !self.checked.wrappedValue
                    self.action()
                }
    }
}
//另外两种构造方式
extension FRadioButton where Label == Text {
    internal init<S>(_ title: S, checked: Binding<Bool>,action callback: @escaping () -> Void = {
    }) where S: StringProtocol {
        self.init(checked: checked, action: callback, label: {
            Text(title)
        })
    }
    internal init(_ titleKey: LocalizedStringKey, checked:Binding<Bool>,action callback: @escaping () -> Void = {
    }) {
        self.init(checked: checked, action: callback, label: {
            Text(titleKey)
        })
    }
}

//每项的数据
struct RadioModel {
    let value: String
    let text: String
    init(_ v: String, _ t: String? = nil) {
        value = v
        if (t == nil) {
            text = v
        } else {
            text = t!
        }
    }
}
//分组实现
struct FRadioGroup: View {
    @State var options: [RadioModel]
    var selected: Binding<String>
    var spacing: CGFloat = 20
    var action: ((RadioModel) -> ())! = nil
    var body: some View {
        ForEach(0 ..< options.count, id:\.self){ index in
            FRadioButton(options[index].text, checked: .constant(selected.wrappedValue == options[index].value) ) {
                selected.wrappedValue = options[index].value
                if (action != nil) {
                    self.action!(options[index])
                }
            }.frame(minWidth:200)
        }
    }
}

我们在视图框中使用如下:
 

struct ContentView: View {
    @State var selection = 0
    @State var check = false
    @State var selected = ""
    var body: some View {
        VStack {
            Text("Hello, World!").frame(maxWidth: .infinity, maxHeight: 100)
            VStack {
                HStack {
                    Text("标题:")
                    VStack {
                        Picker("", selection: $selection) {
                            //每个选项
                            Text("第1个选项").tag(1).frame(minWidth: 80)
                            Text("第2个选项").tag(2).frame(minWidth: 80)
                            Text("第3个选项").tag(3).frame(minWidth: 80)
                            Text("第4个选项").tag(4).frame(minWidth: 80)
                        }
                        Picker("", selection: $selection) {
                            //每个选项
                            Text("第5个选项").tag(5).frame(minWidth: 80)
                            Text("第6个选项").tag(6).frame(minWidth: 80)
                            Text("第7个选项").tag(7).frame(minWidth: 80)
                            Text("第8个选项").tag(8).frame(minWidth: 80)
                        }
                    }.pickerStyle(RadioGroupPickerStyle())
                            .horizontalRadioGroupLayout()
                    Spacer()
                }
            }

            FRadioButton("测试按钮", checked: $check) {
                //配合下面鼠标悬停 显示手势
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
                    NSCursor.pop()
                    NSCursor.pointingHand.push()
                }
            }
                    //添加鼠标手势,不过这里有一个问题,就是 当点击后 不知道什么原因 会被重置 所以 就有了 上面点击0.01秒后再重新设置
                    .onHover { hover in
                        if hover {
                            NSCursor.pointingHand.push()
                        } else {
                            NSCursor.pop()
                        }
                    }
                    .frame(width: 100).foregroundColor(Color.red)

            FRadioGroup(options:[
                RadioModel("1","测试1"),
                RadioModel("2","测试2"),
                RadioModel("3","测试3"),
                RadioModel("4","测试4"),
                RadioModel("5","测试5"),
                RadioModel("6","测试6"),
                RadioModel("7","测试7"),
            ],selected: $selected,spacing:30){ m in
                print(m)
            }.font(.system(size: 16))

            Spacer().frame(maxWidth: .infinity, minHeight: 100)
        }
    }
}

效果如下:

到了这里 我们自定义的RadioGroup 已经实现了,至于排版,我们可以在ForEach 中定义,在大多数情况下,我们希望可以流式布局,自己根据高宽调整。
所以我这里用到了另外一位网友对 流式布局的实现代码,其实如果我实现了也大体会和他做的一样,所以就不自己去实现了
他的链接在这里:https://zhuanlan.zhihu.com/p/352976474
他的代码在最后监视 高度更新的时候忘记调用了刷新
 

.modifier(FrameHeightPreference())
        .onPreferenceChange(ViewKey<FrameHeight>.self) { values in
            values.forEach { val in
                self.frameHeight = val.frameHeight
                //这里原作者忘记这一句了,会导致高度刷新的时候这里布局被重置。
                self.freshRanges()
            }
        }

我这里改过后的代码文件:
WrapForEach.swift 
 


import SwiftUI

struct ViewKey<Element>: PreferenceKey {
    typealias Value = [Element]
    static var defaultValue: Value { Value() }
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value.append(contentsOf: nextValue())
    }
}

struct BindID<ID: Hashable, Value: Hashable>: Hashable {
    let id: ID
    let value: Value
}

struct WidthWithID<ID: Hashable>: ViewModifier {
    typealias Element = BindID<ID, CGFloat>
    let id: ID
    func body(content: Content) -> some View {
        content.background(
            GeometryReader { geo in
                Spacer().preference(
                    key: ViewKey<Element>.self,
                    value: [Element(id: id, value: geo.size.width)]
                )
            }
        )
    }
}

struct FrameWidth: Hashable {
    let frameWidth: CGFloat
}

struct FrameWidthPreference: ViewModifier {
    func body(content: Content) -> some View {
        content.background(
            GeometryReader { geo in
                Spacer().preference(
                    key: ViewKey<FrameWidth>.self,
                    value: [FrameWidth(frameWidth: geo.size.width)]
                )
            }
        )
    }
}

struct FrameHeight: Hashable {
    let frameHeight: CGFloat
}

struct FrameHeightPreference: ViewModifier {
    func body(content: Content) -> some View {
        content.background(
            GeometryReader { geo in
                Color.clear.preference(
                    key: ViewKey<FrameHeight>.self,
                    value: [FrameHeight(frameHeight: geo.size.height)]
                )
            }
        )
    }
}

struct WrapForEach<Data, ID, Content>: View
where Data: RandomAccessCollection, ID: Hashable, Content: View {
  
    let data: Array<Data.Element>
    let keyPath: KeyPath<Data.Element, ID>
    let content: (Data.Element) -> Content
    
    class ViewModel<Data: RandomAccessCollection>: ObservableObject {
           @Published var ranges: [Range<Int>]
           init(data: Data) {
               ranges = [0..<data.count]
           }
    }
    
    @ObservedObject var model: ViewModel<Data>
    @State private var frameWidth: CGFloat = .zero
    @State private var frameHeight: CGFloat = .zero
    @State private var subWidths: [ID: CGFloat] = [:]
    
    init(_ data: Data, id: KeyPath<Data.Element, ID>, @ViewBuilder content: @escaping (Data.Element) -> Content) {
           self.data = data.map { val in val }
           self.keyPath = id
           self.content = content
           self.model = ViewModel(data: data)
       }
    
    var body: some View {
        GeometryReader { reader in
            HStack {
                VStack(alignment: .leading, spacing: 0) {
                    ForEach(model.ranges, id: \.self) { range in
                        HStack(spacing: 0) {
                            ForEach(range) { i in
                                content(data[i]).fixedSize().modifier(WidthWithID(id: data[i][keyPath: keyPath]))
                            }
                        }
                    }
                }.onPreferenceChange(ViewKey<WidthWithID<ID>.Element>.self) { values in
                    self.subWidths = [:]
                    values.forEach { val in
                        self.subWidths[val.id] = val.value
                    }
                    self.freshRanges()
                }
                Spacer(minLength: 0)
            }
            .frame(width: reader.size.width)
                        .modifier(FrameWidthPreference())
                        .onPreferenceChange(ViewKey<FrameWidth>.self) { values in
                            values.forEach { val in
                                self.frameWidth = val.frameWidth
                            }
                            self.freshRanges()
                        }
                        .modifier(FrameHeightPreference())
                        .onPreferenceChange(ViewKey<FrameHeight>.self) { values in
                            values.forEach { val in
                                self.frameHeight = val.frameHeight
                            }
                            self.freshRanges()
                        }
        } .frame(height: frameHeight)
    }
    
    func freshRanges() {
            print("freshRanges")
            guard frameWidth != .zero && subWidths.count == data.count else {
                return
            }
            model.ranges = []
            var start = 0, last = 0, sum: CGFloat = .zero
            for (i, value) in data.enumerated() {
                let width = subWidths[value[keyPath: keyPath]] ?? .zero
                if sum + width >= frameWidth {
                    model.ranges.append(start..<i)
                    sum = .zero
                    start = i
                }
                sum += width
                last = i
            }
            if start <= last && data.count != 0 {
                model.ranges.append(start..<(last + 1))
            }
        }
}

然后在  RadioGroup 视图代码中 把 ForEach 替换成 WrapForEach 即可


struct FRadioGroup: View {
    @State var options: [RadioModel]
    var selected: Binding<String>
    var spacing: CGFloat = 20
    @State  var itemWidth: CGFloat = 80
    @State  var itemHeight: CGFloat = 30
    var action: ((RadioModel) -> ())! = nil
    var body: some View {
        //ForEach 替换成 WrapForEach 就可以实现流式布局了
        WrapForEach(0..<options.count, id: \.self) { index in
            FRadioButton(options[index].text, checked: .constant(selected.wrappedValue == options[index].value)) {
                selected.wrappedValue = options[index].value
                if (action != nil) {
                    self.action!(options[index])
                }
            }.frame(minWidth:itemWidth,minHeight:itemHeight)
        }
    }
}

extension FRadioGroup {
    func itemFrame(width: CGFloat = 0, height: CGFloat = 0) -> some View {
        let _width = width == 0 ? self.itemWidth : width
        let _height = height == 0 ? self.itemHeight : height
        return FRadioGroup(options: self.options, selected: self.selected, spacing: self.spacing, itemWidth: width, itemHeight: height, action: self.action)
    }
}

最终效果如下:

 

阅读量和评论数太少,先暂停更新... 
 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值