SwiftUI의 버튼을 커스텀해서 성공과 실패에 대한 애니메이션을 나타내는 예제다.
먼저 커스텀 버튼을 만든다.
임시로 버튼을 body 안에 작성하고 test를 출력하는 custom button으로 만들었다.
import SwiftUI
struct AnimatedButton: View {
var body: some View {
Button {
print("test")
} label: {
Text("custom button")
}
}
다음으로 버튼을 나타낼 뷰를 먼저 작성한다.
import SwiftUI
struct AnimatedButtonView: View {
var body: some View {
AnimatedButton()
}
}
struct AnimatedButtonView_Previews: PreviewProvider {
static var previews: some View {
AnimatedButtonView()
}
}
아래와 같이 정상적으로 버튼이 추가되었다.
이제 버튼의 상태 값을 추가한다.
성공과 실패에 대한 정보가 주어지기 전까지 버튼이 로딩 중임을 나타내기 위해 isLoading 상태 값을 추가한다.
그리고 실패를 했음을 알려주는 isFailed 상태 값도 추가해준다.
버튼의 상태를 결정하기 위해 Status라는 enum을 만들고 해당 값에 대한 status 상태 값도 추가해준다.
struct AnimatedButton: View {
/// 로딩 상태 표시를 위한 상태 값
@State private var isLoading: Bool = false
/// 실패 분기를 위한 상태 값
@State private var isFailed: Bool = false
/// 작업 수행 상태에 대한 상태 값
@State private var status: Status = .idle
var body: some View {
Button {
print("test")
} label: {
Text("custom button")
}
}
enum Status: Equatable {
case idle
case failed
case success
}
버튼을 생성할 때 어떤 action을 수행할 것인지와 버튼 내부에 어떤 View를 그릴 것인지 받기 위한 프로퍼티를 추가한다.
struct AnimatedButton<ButtonContent: View>: View {
var content: () -> ButtonContent
var action: () async -> Status
/// 로딩 상태 표시를 위한 상태 값
@State private var isLoading: Bool = false
/// 실패 분기를 위한 상태 값
@State private var isFailed: Bool = false
/// 작업 수행 상태에 대한 상태 값
@State private var status: Status = .idle
var body: some View {
Button {
Task {
await action()
}
} label: {
content()
}
}
enum Status: Equatable {
case idle
case failed
case success
}
이제 외부에서 버튼을 만들 때에 기존 버튼과 유사하게 content와 action에 대한 정보를 넘겨주어야 한다.
아래와 같이 작성하면 버튼의 모양과 동작이 반영되는 것을 알 수 있다.
struct AnimatedButtonView: View {
var body: some View {
AnimatedButton {
Text("Animated Button")
} action: {
print("test")
}
}
}
이제 동작을 수행한 후, 주어진 결과에 따라 일정 시간 버튼이 성공 및 실패 여부를 나타내도록 수정한다.
Button {
Task {
/// 동작을 수행하기 직전에 로딩 상태로 변경
isLoading = true
/// 주어진 동작 수행
let status = await action()
/// 동작 수행이 끝나고 성공 및 실패 여부를 isFailed에 저장
isFailed = status == .failed ? true : false
/// 버튼 상태를 status로 설정
self.status = status
/// 0.5초 간 성공 및 실패 여부를 유지한 후 idle 상태로 복귀
try? await Task.sleep(for: .seconds(0.5))
self.status = .idle
/// 모든 작업이 끝난 후 로딩 상태 해제
isLoading = false
}
} label: {
content()
}
버튼이 실시간으로 현재 상태에 대한 상태 값을 지니도록 변경되었다.
content로 표현되는 View를 상태 값에 따라 적절하게 나타나도록 수정한다.
Button {
Task { ... }
} label: {
content()
.foreground(.white)
.padding(.horizontal, 20)
.padding(.vertical, 15)
/// 로딩 중에는 기존 view를 숨김
.opacity(isLoading ? 0 : 1)
/// 텍스트가 개행되는 것을 막음
.lineLimit(1)
/// 로딩 중에는 버튼 사이즈를 50x50으로 고정
.frame(width: isLoading ? 50 : nil, height: isLoading ? 50 : nil)
/// status에 따라 버튼 배경색을 지정
.background(Color(status == .idle ? .systemBlue : status == .success ? .systemMint : .systemRed).shadow(.drop(color: .gray.opacity(0.2), radius: 6)), in: Capsule())
/// 로딩 중이면서 idle 상태일 때는 ProgressView를 나타냄
.overlay {
if isLoading && status == .idle {
ProgressView()
}
}
/// 실패 시에는 느낌표 이미지를 나타내고 성공 시에는 체크마크 이미지를 나타냄
.overlay{
if status != .idle {
Image(systemName: isFailed ? "exclamationmark" : "checkmark")
.fontWeight(.black)
.foregroundStyle(.white)
}
}
}
/// 로딩 중에는 버튼을 비활성화
.disabled(isLoading)
/// 상태 값 변경 시에 애니메이션을 추가해서 부드럽게 전환
.animation(.easeInOut, value: isLoading)
.animation(.easeInOut, value: status)
토글을 추가해서 성공과 실패에 따른 버튼 UI 변경을 눈으로 확인한다.
struct AnimatedButtonView: View {
@State private var isSucess: Bool = true
var body: some View {
VStack(spacing: 30) {
Toggle("성공여부", isOn: $isSucess)
.frame(width: 120)
AnimatedButton {
Text("Animated Button")
} action: {
/// 테스트를 위해 2초가 소요되는 작업이 진행되었다고 가정
try? await Task.sleep(for: .seconds(2))
/// 토글에 따라 성공 여부를 가정
return isSucess ? .success : .failed
}
}
}
}
토글 여부에 따라 성공과 실패 UI가 잘 반영되었음을 확인할 수 있다.
추가적으로 아래와 같이 tint 색상에 대한 프로퍼티를 추가하면 다크모드 등 주변 UI에 대응하기 위한 tint 설정을 제공할 수 있다.
struct AnimatedButton<ButtonContent: View>: View {
var tint: Color = .white
var content: () -> ButtonContent
var action: () async -> Status
/// 로딩 상태 표시를 위한 상태 값
@State private var isLoading: Bool = false
/// 실패 분기를 위한 상태 값
@State private var isFailed: Bool = false
/// 작업 수행 상태에 대한 상태 값
@State private var status: Status = .idle
var body: some View {
Button {
Task { ... }
} label: {
content()
/// tint 색상을 지정하면 글자 색을 흰색으로 고정
.foregroundColor(tint == .white ? nil : .white)
.padding(.horizontal, 20)
.padding(.vertical, 15)
.opacity(isLoading ? 0 : 1)
.lineLimit(1)
.frame(width: isLoading ? 50 : nil, height: isLoading ? 50 : nil)
.background(Color(status == .idle ? UIColor(tint) : status == .success ? .systemMint : .systemRed).shadow(.drop(color: .gray.opacity(0.2), radius: 6)), in: Capsule())
.overlay {
if isLoading && status == .idle {
ProgressView()
}
}
.overlay{
if status != .idle {
Image(systemName: isFailed ? "exclamationmark" : "checkmark")
.fontWeight(.black)
.foregroundStyle(.white)
}
}
}
.disabled(isLoading)
.animation(.easeInOut, value: isLoading)
.animation(.easeInOut, value: status)
}
}
전체 코드 Repo
https://github.com/110w110/SwiftUIExample/tree/master
GitHub - 110w110/SwiftUIExample
Contribute to 110w110/SwiftUIExample development by creating an account on GitHub.
github.com
'Swift > SwiftUI' 카테고리의 다른 글
[SwiftUI] 드래그 가능한 그리드 만들기 (0) | 2024.01.22 |
---|