티스토리 뷰

최근에 진행한 Weave 프로젝트를 진행하면서 Custom Alert을 구현할 기회가 있었다. 

UIKit에서의 Custom Alert이 아닌, SwiftUI 환경에서의 Alert은 첫 구현이라 어떨지 궁금했고, 

결과적으로 UIKit보다 더욱 간편하게 구현이 가능했다.

또한 어떻게 SwiftUI스럽게 구현할 수 있을지 고민해보았다.

 

1. 디자인 요구사항

Alert 디자인 요구사항

디자인 에서 요구되는 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)
    }
)

 

전체코드

https://github.com/Student-Center/weave-ios/blob/develop/weave-iOS/Projects/DesignSystem/Sources/Alert/WeaveAlert.swift

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/02   »
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
글 보관함