티스토리 뷰
최근에 진행한 Weave 프로젝트를 진행하면서 Custom Alert을 구현할 기회가 있었다.
UIKit에서의 Custom Alert이 아닌, SwiftUI 환경에서의 Alert은 첫 구현이라 어떨지 궁금했고,
결과적으로 UIKit보다 더욱 간편하게 구현이 가능했다.
또한 어떻게 SwiftUI스럽게 구현할 수 있을지 고민해보았다.
1. 디자인 요구사항
디자인 에서 요구되는 Alert은 이러한 모습이다.
위 요구사항을 다음과 같이 분석해 코드에 옮기기로 했다.
2. 뷰 구성하기
public struct WeaveAlert: View {
@Binding var isPresented: Bool
let title: String
var message: String?
let primaryButtonTitle: String
let primaryButtonColor: Color
var secondaryButtonTitle: String?
var primaryAction: (() -> Void)?
var secondaryAction: (() -> Void)?
init(
isPresented: Binding<Bool>,
title: String,
message: String? = nil,
primaryButtonTitle: String,
primaryButtonColor: Color = DesignSystem.Colors.defaultBlue,
secondaryButtonTitle: String? = nil,
primaryAction: (() -> Void)? = nil,
secondaryAction: (() -> Void)? = nil
) {
self._isPresented = isPresented
self.title = title
self.message = message
self.primaryButtonTitle = primaryButtonTitle
self.primaryButtonColor = primaryButtonColor
self.secondaryButtonTitle = secondaryButtonTitle
self.primaryAction = primaryAction
self.secondaryAction = secondaryAction
}
...
}
위 코드와 같이 뷰의 생성자를 만들어주었다.
분석한 대로 title, message를 받았으며,
각 버튼별 title 및 color, 액션을 받고 기본값까지 할당해주었다.
private var viewWidth: CGFloat {
return UIScreen.main.bounds.size.width - 60
}
public var body: some View {
ZStack {
VStack(spacing: 0) {
VStack(spacing: 16) {
Text(title)
.multilineTextAlignment(.center)
.font(.pretendard(._600, size: 16))
.lineSpacing(8)
if let message {
Text(message)
.multilineTextAlignment(.center)
.font(.pretendard(._400, size: 14))
.lineSpacing(2)
}
}
.padding(.top, 22)
.padding(.bottom, 16)
HStack(spacing: 8) {
if let secondaryButtonTitle {
WeaveButton(
title: secondaryButtonTitle,
size: .large,
backgroundColor: DesignSystem.Colors.lightGray
) {
isPresented.toggle()
secondaryAction?()
}
.frame(width: getButtonWidth(ratio: 0.3))
}
WeaveButton(
title: primaryButtonTitle,
size: .large,
backgroundColor: primaryButtonColor
) {
isPresented.toggle()
primaryAction?()
}
.frame(width: getButtonWidth(ratio: 0.7))
}
.frame(width: viewWidth - 40)
.padding(.vertical, 16)
}
.padding(.horizontal, 20)
}
.background(DesignSystem.Colors.darkGray)
.clipShape(
RoundedRectangle(cornerRadius: 14)
)
.frame(width: viewWidth)
}
private func getButtonWidth(ratio: CGFloat) -> CGFloat {
let buttonStackWidth = viewWidth - 40 - 8
return buttonStackWidth * ratio
}
뷰는 생각해보다 간단하게 그릴 수 있었다.
- Vstack 내부에 title과 message를 넣고, 그 아래에는 버튼 Hstack을 넣었다.
- 버튼은 DesignSystem으로 미리 구현해놓은 버튼을 사용했으며, title이 들어온 경우에 생성하도록 했다.
- 버튼 선택시 isPresend를 false로 바꿔 뷰가 닫히도록 구현했다.
- 기타 padding들과 background 색상 등을 설정해주었다.
3. 애니메이션 구현하기
Alert이 아래에서 슉~ 하고 튕겨 올라오는 애니메이션을 구현하고 싶었다.
그렇게 하려면 두가지 조건이 필요했다.
1. 백그라운드 뷰 전체는 살짝 반투명한 상태로 전환 되어야 함
2. 뷰 전체가 올라오는 것이 아닌, Alert 다이얼로그 창만 위로 올라와야 함
먼저 백그라운드 뷰를 반투명한 상태로 구현하기 위해 상위 뷰를 Zstack으로 바꾸고, 백그라운드 컬러를 깔았다.
Color 는 화면을 꽉 채우려고 하기 때문에 최상위 ZStack은 화면 전체를 차지할 것이다.
backgroundOpacity 값을 통해 0.0 ~ 1.0 사이의 alpha 값으로 렌더링 하도록 구현했다.
그리고 뷰가 onAppear 되면 withAnimation을 통해 backgroundOpacity를 기본값 0.0 에서 0.4로 변경하도록 구현했다.
그러면 뷰가 그려질 때 자연스럽게 disolve 애니메이션 처리가 된다.
@State private var backgroundOpacity = 0.0
@State private var zStackOffset = UIScreen.main.bounds.size.height / 2
public var body: some View {
ZStack {
// 백그라운드 컬러 깔기
DesignSystem.Colors.black.opacity(backgroundOpacity).ignoresSafeArea()
ZStack {
...
}
.background(DesignSystem.Colors.darkGray)
.clipShape(
RoundedRectangle(cornerRadius: 14)
)
.frame(width: viewWidth)
.offset(y: zStackOffset)
}
.onAppear {
withAnimation(.easeOut(duration: 0.15)) {
backgroundOpacity = 0.4
}
withAnimation(.spring(.bouncy(duration: 0.2))) {
zStackOffset = 0
}
}
}
위로 튕겨져 올라오는 애니메이션은 offset을 통해 구현했다.
매번 느끼지만 SwiftUI는 애니메이션을 잘 감싸놔서 구현하기 참 편하다.
zStackOffset 을 @State 로 만들어주고,
내부 Alert에 해당하는 뷰에 .offset(y: ) 값에 걸어주게 되면,
zStackOffset이 변할 때 마다 렌더링이 되어 위치값이 변한다.
zStackOffset의 초기값을 조절해 뷰의 아래로 내려보낸 곳에서 생성되게 한 뒤에,
마찬가지로 onAppear에서 offset을 0으로 복귀시키면 아래에서 올라오는 애니메이션이 간단하게 만들어진다.
withAnimation에서는 animation의 타입을 넣어줄 수 있는데,
이러한 애니메이션은 .easyInOut 을 넣기도 하지만 튕기는(?) 효과인 .bouncy 를 적용했다.
그렇게 만든 애니메이션은 다음과 같다.
4. 사용하기
해당 Alert을 뷰에서 편하게 사용하기 위해 modifier 로 만들어 감싸주었다.
특히, tranparentFullScreenCover 는 SwiftUI 환경에서 투명한 뷰 백그라운드를 사용하기 위한 modifier고,
tansaction 은 sheet 가 올라오는 기본 animation 을 비활성화 하기 위해 구현했다.
다만, 글로벌로 적용되기 때문에 들어온 isPresent 일 때만 disable 되도록 했다.
public struct WeaveAlertModifier: ViewModifier {
@Binding var isPresented: Bool
let weaveAlert: WeaveAlert
public func body(content: Content) -> some View {
content
.transparentFullScreenCover(isPresented: $isPresented) {
weaveAlert
}
.transaction({ transaction in
transaction.disablesAnimations = isPresented
})
}
}
public extension View {
func weaveAlert(
isPresented: Binding<Bool>,
title: String,
message: String? = nil,
primaryButtonTitle: String,
primaryButtonColor: Color = DesignSystem.Colors.defaultBlue,
secondaryButtonTitle: String? = nil,
primaryAction: (() -> Void)? = nil,
secondaryAction: (() -> Void)? = nil
) -> some View {
let alert = WeaveAlert(
isPresented: isPresented,
title: title,
message: message,
primaryButtonTitle: primaryButtonTitle,
primaryButtonColor: primaryButtonColor,
secondaryButtonTitle: secondaryButtonTitle,
primaryAction: primaryAction,
secondaryAction: secondaryAction
)
return modifier(WeaveAlertModifier(isPresented: isPresented, weaveAlert: alert))
}
}
뷰에서 사용할 때는 다음과 같다.
최대한 SwiftUI와 어울리고, 개발할 때 간단하게 구현이 가능하도록 구현했다.
.weaveAlert(
isPresented: viewStore.$isShowInvitationConfirmAlert,
title: "✉️\n팀 초대장 도착!",
message: "\(viewStore.invitedTeamInfo?.teamIntroduce ?? "") 팀의 초대를 수락할까요?",
primaryButtonTitle: "수락할께요",
secondaryButtonTitle: "나중에",
primaryAction: {
viewStore.send(.didTappedAcceptInvitation)
},
secondaryAction: {
viewStore.send(.didTappedCancelInvitation)
}
)
전체코드
'iOS' 카테고리의 다른 글
[iOS] Dateformatter 가 고장나서 이상했던 경험 (0) | 2024.08.07 |
---|---|
[iOS] 단일타겟 프로젝트를 멀티모듈로 바꾸었다. (1) | 2024.05.16 |
[iOS] SwiftUI 섹션이 있는 메뉴 리스트 만들기 (0) | 2024.04.22 |
[iOS] 서버 환경 분리하기(prod/dev, with Tuist) (0) | 2024.04.19 |
[iOS] SwiftUI 그라데이션 응용하기 (Stepper) (0) | 2024.04.17 |
- Total
- Today
- Yesterday
- keyboardtype
- demical
- 2024년
- open-api-generator
- watch connectivity
- SwiftUI
- openapi-generator
- 애플워치
- 토큰
- AVFoundation
- easy cue
- Xcode15
- locale
- TextField
- musicplayer
- KVO
- avplayer
- OAS
- auth
- 애플워치 데이터 전송
- Xcode
- flo
- IOS
- 소수점
- watchOS
- Swift
- DateFormatter
- 회고
- swift날짜
- retry
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |