在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)
}
}
最终效果如下:
阅读量和评论数太少,先暂停更新...