SwiftUI - 라디오 버튼 만들기
SwiftUI로 간단하게 뭐 만들다가 필요해서 만들어 봄
라디오 버튼은 UIKit으로는 만들었었는데 SwiftUI로는 만들어 본 적이 없다
선언형 UI인 Flutter는 써봤지만 SwiftUI를 써본 적이 별로 없어서 한 번 UIKit이랑 비교하면서 만들어보기로 함
결과물
계획
생각해 본 구조는
- 토글되는 모양 뷰(위에선 원)
- 1과 실제 옵션 text가 포함된 뷰(하나의 버튼 아이템)
- 2들의 집합, 이 중 하나만 선택 되도록 하는 로직 포함
여기에 애니메이션, 툴팁, 커스터마이징 가능하게 등을 추가해보자
RadioButtonShape
우선 러프하게 짜 봄
struct RadioButtonShape: View {
static let shapeSize = 16.0
static let innerSize = 8.0
@Binding var isSelected: Bool
var body: some View {
Circle()
.stroke(.secondary, lineWidth: 2.0)
.frame(width: RadioButtonShape.shapeSize, height: RadioButtonShape.shapeSize)
.overlay(
Circle()
.foregroundColor(.secondary)
.frame(width: RadioButtonShape.innerSize, height: RadioButtonShape.innerSize)
.opacity(isSelected ? 1.0 : 0.0)
.animation(.easeIn(duration: 0.2), value: isSelected)
)
}
}
일단 SwiftUI에는 Shape
를 제공한다. 간단하게 Circle
, Rectangle
등이 있다.
이걸 사용하면 원 안의 원 구조가 쉽게 구현이 된다.
protocol Shape : Animatable, View
Shape
도 역시 프로토콜이고 View
를 따른다.
UIKit으로 할 때는 뷰의 cornerRadius를 줘서 원을 만들고 border를 추가해서 바깥쪽의 원, layer를 추가해서 내부 원을 path로 그려줬었는데, 그거 비하면 훨 간단한 거 같다
그럼 CALayer 개념은 사라진 건가?? 해서 찾아보다가 WWDC19 Building Custom Views with SwiftUI를 봤는데, 24분 쯤부터 그래픽 부분이 시작하는데, SwiftUI에선 모든 게 뷰, 드로잉도 결국 뷰를 만든다고 하는 부분이 나온다
지금 슬쩍 기초만 보고 있지만 코드를 좀 보다 보면 진짜 다 ‘어쨌든 View 프로토콜을 따르는 무언가’ 취급을 한다. 뭔가 이해 되는 거 같기도 하고
RadioButtonItem
struct RadioButtonItem: View {
var title = "aaaaaa"
@State var isSelected = false
var body: some View {
HStack {
RadioButtonShape(isSelected: $isSelected)
Text(title)
}
.contentShape(Rectangle())
.gesture(
TapGesture()
.onEnded({ _ in
isSelected = !isSelected
})
)
}
}
HStack
으로 Text
와 RadioButtonShape
를 가진다. 전체를 감싸서 탭 제스처를 인식하고, 상태 변수인 isSelected
를 토글하도록 했다.
isSelected
는 RadioButtonShape
에 바인딩으로 넘겨서, 토글될 때마다 RadioButtonShape
도 업데이트 된다.
.contentShape(Rectangle())
처음에 이 코드 없이 그냥 했더니,RadioButtonShape
을 탭해도 제스처가 인식이 안 되는 문제가 있었다. 선택되지 않은 모양의 경우, 가운데가 비어 있는데 투명한 부분은 탭 인식이 안 되어서 그랬음 그래서contentShape
로 히트 박스를 HStack 네모 전체로 설정함
중간 결과
아무튼 이까지의 결과물을 보면 이렇게
위에서 계획한 1번(토글되는 모양 뷰), 2번(아이템)까지 완성
UIKit에 비해 훨씬 코드가 짧고 빨리 짜지는 기분ㄷㄷ
RadioButtons
struct RadioButtons: View {
let options: [String]
@Binding var selectedIndex: Int
var body: some View {
HStack(spacing: 10.0) {
ForEach(Array(options.indices), id: \.self) { i in
RadioButtonItem(id: i, title: options[i], selectedIndex: $selectedIndex)
}
}
}
}
RadioButtonItem
그룹을 가지는 뷰 추가
찾아보니 기존 forEach와 비슷하게 동작하는 ForEach
역시 View
를 따르며, struct다. id
는 바뀐 부분만 효율적으로 뷰를 다시 그리기 위해 받는다고 함.
selectedIndex
를 바인딩으로 가져 외부에서 인덱스의 변화를 알 수 있도록 했다.
RadioButtonItem
쪽의 로직도 단순히 토글되는 것이 아닌 그룹에 알맞게 동작해야 하므로 수정해주자
struct RadioButtonItem: View {
let id: Int
let title: String
@Binding var selectedIndex: Int
var body: some View {
HStack(spacing: 5.0) {
RadioButtonShape(isSelected: Binding(get: { selectedIndex == id }, set: { _ in }))
Text(title)
}
.contentShape(Rectangle())
.gesture(
TapGesture()
.onEnded({ _ in
if selectedIndex != id {
selectedIndex = id
}
})
)
}
}
id를 추가로 입력 받고, 기존 @State var isSelected
를 @Binding var selectedIndex
로 변경했다.
그러나 RadioButtonShape
부분까지 인덱스 정보를 줄 필요는 없기 때문에, Binding(get: { selectedIndex == id }, set: { _ in }
를 통해 바인딩 변수를 Bool로 변환하여 넘겨주도록 한다.
또, 탭 제스처에서 현재 선택된 인덱스가 내 id와 다를 경우에만 업데이트 하도록 함.
struct RadioButtonShape: View {
static let shapeSize = 16.0
static let innerSize = 8.0
@Binding var isSelected: Bool
var body: some View {
Circle()
.stroke(isSelected ? .blue : .secondary,
lineWidth: isSelected ? 2.0 : 1.5)
.frame(width: RadioButtonShape.shapeSize,
height: RadioButtonShape.shapeSize)
.overlay(
Circle()
.foregroundColor(isSelected ? .blue : .secondary)
.frame(width: RadioButtonShape.innerSize,
height: RadioButtonShape.innerSize)
.opacity(isSelected ? 1.0 : 0.0)
)
.animation(.easeIn(duration: 0.2), value: isSelected)
}
}
RadioButtonShape
쪽은 UI 좀 더 다듬기
현재 상태에 따라 색깔, lineWidth
등의 변경도 추가
이까지 하면 위에서 본 결과 중 기본 기능은 다 넣었다!!
여기다가 이제 툴팁도 넣고, 좀 더 자유로운 커스터마이징도 추가해 보자
툴팁(popover) 추가
struct RadioButtonItem: View {
@State private var showPopover = false
var body: some View {
HStack() {
// ...
}
.popover(isPresented: $showPopover) {
Text(description)
.padding(padding)
}
.gesture(
LongPressGesture()
.onEnded({ _ in
showPopover = true
})
)
}
}
RadioButtonItem
에 툴팁을 추가하자
저번에 하던 프로젝트에서는 UIKit에는 툴팁이 없어서… 안드로이드 팀도 그렇고 딴 거 개발할 것도 너무 많아서 결국 툴팁을 없애는 방향으로 디자인 명세를 수정 부탁드렸던 적이 있었는데… SwiftUI는 매우 간단하게 추가가 가능하다(UIKit의 popover는 아이패드에서만 툴팁 형태로 됨)
상태 변수로 showPopover
를 추가하고, 꾹 눌렀을 때 이걸 true로 지정하면 됨
그리고 .popover(isPresented:)
에 바인딩 변수로 넘겨주면 된다. 한 번 showPopover
가 true가 되어 팝오버가 뜨면 알아서 false로 바꿔줌 굿
역시 content 클로저를 받으므로, 뷰 만들고 싶은대로 만들면 끝
커스터마이징
사용에 있어 자유도를 높이자
일단 RadioButtonShape
가 굳이 원 모양만 사용하는 게 별로다. 보통 네모 안에 체크 모양 등 다양하게 조합이 가능하면 좋겠다. 색상도 그렇고
struct RadioButtonShape: View {
struct Configurations {
var shape: any Shape = Circle()
var innerShape: any Shape = Circle()
var shapeSize: CGFloat = 16.0
var innerSize: CGFloat = 8.0
var color: Color = .secondary
var selectedColor: Color = .blue
var lineWidth: CGFloat = 1.5
var selectedLineWidth: CGFloat = 2.0
}
let configs: Configurations
}
Configurations
을 추가하고 UI는 전부 configs
의 값을 사용하도록 수정
struct RadioButtonItem: View {
struct Configurations {
var font: Font = .body
var spacing: CGFloat = 5.0
var popoverPadding: CGFloat = 16.0
}
}
비슷하게 RadioButtonItem
도 폰트 등을 받게 하면
RadioButtons(shapeConfigs: .init(shape: Rectangle(), innerShape: Checkmark()),
options: radioOptions,
descriptions: radioDescriptions,
selectedIndex: $selectedIndex)
이런 식으로 자유롭게 쓸 수 있다.
Checkmark
는 직접 추가한 커스텀 shape
Checkmark
struct Checkmark: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX, y: rect.fractionY(3, 4)))
path.addLine(to: CGPoint(x: rect.fractionX(1, 3), y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.fractionY(1, 3)))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.fractionX(1, 3), y: rect.fractionY(3, 4)))
path.addLine(to: CGPoint(x: rect.minX, y: rect.fractionY(2, 4)))
path.addLine(to: CGPoint(x: rect.minX, y: rect.fractionY(3, 4)))
return path
}
}
Path
를 제공해주면 된다.
위치 계산을 편하게 하려고 fractionX
랑 fractionY
도 CGRect
에 extension으로 추가해 줬다
extension CGRect {
func fractionX(_ numerator: Int, _ denominator: Int) -> CGFloat {
return (maxX - minX) / CGFloat(denominator) * CGFloat(numerator) + minX
}
func fractionY(_ numerator: Int, _ denominator: Int) -> CGFloat {
return (maxY - minY) / CGFloat(denominator) * CGFloat(numerator) + minY
}
}
minX ~ maxX 사이의 3분의 1 지점 구하기 등을 rect.fractionX(1, 3)
으로 사용
마지막으로 기존 RadioButtons
에서 HStack을 가지고 있어서 가로 방향밖에 안 됐었는데
struct RadioButtonsCollection: View {
var body: some View {
ForEach(Array(options.indices), id: \.self) { i in
RadioButtonItem(configs: itemConfigs,
shapeConfigs: shapeConfigs,
id: i,
title: options[i],
description: descriptions[i],
selectedIndex: $selectedIndex)
}
}
}
스택은 없애고 그냥 컬렉션으로 바꿔서
HStack {
RadioButtonsCollection(...)
}
VStack(alignment: .leading) {
RadioButtonsCollection(...)
}
사용할 때 쓰고 싶은 Stack 방향대로 사용할 수 있게 하기
근데 이게 또 신기한 게
UIKit에서는 UIStackView에다 서브뷰들 담긴 뷰 넣어도 해당 뷰만 정렬하지 서브뷰들이 정렬되지는 않는데
지금 RadioButtonsCollection
이 ForEach
로 만들어낸 body
라는 하나의 View
를 제공하는데 HStack
이나 VStack
에 넣으면 알아서 해당 스택 정렬을 따르네요
alignment
가 안 정해진 서브뷰들은 부모 거를 따르게 되는 건가??
최종 코드
최종 코드
struct RadioButtonsCollection: View {
var itemConfigs: RadioButtonItem.Configurations = .init()
var shapeConfigs: RadioButtonShape.Configurations = .init()
let options: [String]
let descriptions: [String]
@Binding var selectedIndex: Int
var body: some View {
ForEach(Array(options.indices), id: \.self) { i in
RadioButtonItem(configs: itemConfigs,
shapeConfigs: shapeConfigs,
id: i,
title: options[i],
description: descriptions[i],
selectedIndex: $selectedIndex)
}
}
}
struct RadioButtonItem: View {
struct Configurations {
var font: Font = .body
var spacing: CGFloat = 5.0
var popoverPadding: CGFloat = 16.0
}
let configs: Configurations
var shapeConfigs: RadioButtonShape.Configurations
let id: Int
let title: String
let description: String
@Binding var selectedIndex: Int
@State private var showPopover = false
var body: some View {
HStack(spacing: configs.spacing) {
RadioButtonShape(configs: shapeConfigs,
isSelected: Binding(get: { selectedIndex == id }, set: { _ in }))
Text(title)
.font(configs.font)
}
.contentShape(Rectangle())
.popover(isPresented: $showPopover) {
Text(description)
.padding(configs.popoverPadding)
}
.gesture(
TapGesture()
.onEnded({ _ in
if selectedIndex != id {
selectedIndex = id
}
})
)
.gesture(
LongPressGesture()
.onEnded({ _ in
showPopover = true
})
)
}
}
struct RadioButtonShape: View {
struct Configurations {
var shape: any Shape = Circle()
var innerShape: any Shape = Circle()
var shapeSize: CGFloat = 16.0
var innerSize: CGFloat = 8.0
var color: Color = .secondary
var selectedColor: Color = .blue
var lineWidth: CGFloat = 1.5
var selectedLineWidth: CGFloat = 2.0
}
let configs: Configurations
@Binding var isSelected: Bool
var body: some View {
AnyShape(configs.shape)
.stroke(isSelected ? configs.selectedColor : configs.color,
lineWidth: isSelected ? configs.selectedLineWidth : configs.lineWidth)
.frame(width: configs.shapeSize,
height: configs.shapeSize)
.overlay(
AnyShape(configs.innerShape)
.foregroundColor(isSelected ? configs.selectedColor : configs.color)
.frame(width: configs.innerSize,
height: configs.innerSize)
.opacity(isSelected ? 1.0 : 0.0)
)
.animation(.easeIn(duration: 0.2), value: isSelected)
}
}
굿
댓글남기기