Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TNT-139] TControlButton, TPopUp 컴포넌트 코드 작성 #21

Merged
merged 6 commits into from
Jan 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// TControlButton.swift
// DesignSystem
//
// Created by 박민서 on 1/15/25.
// Copyright © 2025 yapp25thTeamTnT. All rights reserved.
//

import SwiftUI

/// TnT 앱 내에서 전반적으로 사용되는 커스텀 컨트롤 버튼 컴포넌트입니다.
public struct TControlButton: View {
/// 버튼 기본 사이즈
static private let defaultSize: CGSize = .init(width: 24, height: 24)
/// 버튼 탭 액션
private let tapAction: () -> Void
/// 버튼 스타일
private let type: Style
/// 버튼 선택 상태
@Binding private var isSelected: Bool

/// TControlButton 생성자
/// - Parameters:
/// - type: 버튼의 스타일. `TControlButton.Style` 사용.
/// - isSelected: 버튼의 선택 상태를 관리하는 바인딩.
/// - action: 버튼이 탭되었을 때 실행할 액션. (기본값: 빈 클로저)
public init(
type: Style,
isSelected: Binding<Bool>,
action: @escaping () -> Void = {}
) {
self.type = type
self._isSelected = isSelected
self.tapAction = action
}

public var body: some View {
Button(action: {
tapAction()
}, label: {
type.image(isSelected: isSelected)
.resizable()
.scaledToFit()
.frame(width: TControlButton.defaultSize.width, height: TControlButton.defaultSize.height)
})
}
}

public extension TControlButton {
/// TControlButton의 스타일입니다.
enum Style {
case radio
case checkMark
case checkbox
case star
case heart

/// 선택 상태에 따른 이미지 반환
func image(isSelected: Bool) -> Image {
switch self {
case .radio:
return Image(isSelected ? .icnRadioButtonSelected : .icnRadioButtonUnselected)
case .checkMark:
return Image(isSelected ? .icnCheckMarkFilled : .icnCheckMarkEmpty)
case .checkbox:
return Image(isSelected ? .icnCheckButtonSelected : .icnCheckButtonUnselected)
case .star:
return Image(isSelected ? .icnStarFilled : .icnStarEmpty)
case .heart:
return Image(isSelected ? .icnHeartFilled : .icnHeartEmpty)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// TToggleStyle.swift
// DesignSystem
//
// Created by 박민서 on 1/15/25.
// Copyright © 2025 yapp25thTeamTnT. All rights reserved.
//

import SwiftUI

/// TToggleStyle: ViewModifier
/// SwiftUI의 `Toggle`에 TnT 스타일을 적용하기 위한 커스텀 ViewModifier입니다.
/// 기본 크기와 토글 스타일을 설정하여 재사용 가능한 스타일링을 제공합니다.
struct TToggleStyle: ViewModifier {
/// 기본 토글 크기
static let defaultSize: CGSize = .init(width: 44, height: 24)

/// `ViewModifier`가 적용된 뷰의 구성
/// - Parameter content: 스타일이 적용될 뷰
/// - Returns: TnT 스타일이 적용된 뷰
func body(content: Content) -> some View {
content
.toggleStyle(SwitchToggleStyle(tint: .red500))
.labelsHidden()
.frame(width: TToggleStyle.defaultSize.width, height: TToggleStyle.defaultSize.height)
}
}

/// Toggle 확장: TnT 스타일 적용
public extension Toggle {
/// SwiftUI의 기본 `Toggle`에 TnT 스타일을 적용합니다.
func applyTToggleStyle() -> some View {
self.modifier(TToggleStyle())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//
// TPopupAlertState.swift
// DesignSystem
//
// Created by 박민서 on 1/15/25.
// Copyright © 2025 yapp25thTeamTnT. All rights reserved.
//

import SwiftUI
import ComposableArchitecture

/// TPopUpAlertView에 표시하는 정보입니다.
/// 팝업의 제목, 메시지, 버튼 정보를 포함.
public struct TPopupAlertState: Equatable {
/// 팝업 제목
public var title: String
/// 팝업 메시지 (옵션)
public var message: String?
/// 팝업에 표시될 버튼 배열
public var buttons: [ButtonState]

/// TPopupAlertState 초기화 메서드
/// - Parameters:
/// - title: 팝업의 제목
/// - message: 팝업의 메시지 (선택 사항, 기본값: `nil`)
/// - buttons: 팝업에 표시할 버튼 배열 (기본값: 빈 배열)
public init(
title: String,
message: String? = nil,
buttons: [ButtonState] = []
) {
self.title = title
self.message = message
self.buttons = buttons
}
}

public extension TPopupAlertState {
// TODO: 버튼 컴포넌트 완성 시 수정
/// TPopUpAlertView.AlertButton에 표시하는 정보입니다.
struct ButtonState: Equatable {
/// 버튼 제목
public let title: String
/// 버튼 스타일
public let style: Style
/// 버튼 클릭 시 동작
public let action: EquatableClosure

public enum Style {
case primary
case secondary
}

/// TPopupAlertState.ButtonState 초기화 메서드
/// - Parameters:
/// - title: 버튼 제목
/// - style: 버튼 스타일 (기본값: `.primary`)
/// - action: 버튼 클릭 시 동작
public init(
title: String,
style: Style = .primary,
action: EquatableClosure
) {
self.action = action
self.title = title
self.style = style
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//
// TPopUpAlertView.swift
// DesignSystem
//
// Created by 박민서 on 1/16/25.
// Copyright © 2025 yapp25thTeamTnT. All rights reserved.
//

import SwiftUI

/// 팝업 Alert의 콘텐츠 뷰
/// 타이틀, 메시지, 버튼 섹션으로 구성.
public struct TPopUpAlertView: View {
/// 팝업 상태 정보
private let alertState: TPopupAlertState

/// - Parameter alertState: 팝업에 표시할 상태 정보
public init(alertState: TPopupAlertState) {
self.alertState = alertState
}

public var body: some View {
VStack(spacing: 20) {
// 텍스트 Section
VStack(spacing: 8) {
Text(alertState.title)
.typographyStyle(.heading4, with: .neutral900)
.multilineTextAlignment(.center)
.padding(.top, 20)
if let message = alertState.message {
Text(message)
.typographyStyle(.body2Medium, with: .neutral500)
.multilineTextAlignment(.center)
}
}

// 버튼 Section
HStack {
ForEach(alertState.buttons, id: \.title) { buttonState in
buttonState.toButton()
}
}
}
}
}

public extension TPopUpAlertView {
// TODO: 버튼 컴포넌트 완성 시 수정
struct AlertButton: View {
let title: String
let style: TPopupAlertState.ButtonState.Style
let action: () -> Void

init(
title: String,
style: TPopupAlertState.ButtonState.Style,
action: @escaping () -> Void
) {
self.title = title
self.style = style
self.action = action
}

public var body: some View {
Button(action: action) {
Text(title)
.typographyStyle(.body1Medium, with: style == .primary ? Color.neutral50 : Color.neutral500)
.padding()
.frame(maxWidth: .infinity)
.background(style == .primary ? Color.neutral900 : Color.neutral100)
.foregroundColor(.white)
.cornerRadius(8)
}
}
}
}

public extension TPopupAlertState.ButtonState {
/// `ButtonState`를 `AlertButton`으로 변환
func toButton() -> TPopUpAlertView.AlertButton {
TPopUpAlertView.AlertButton(
title: self.title,
style: self.style,
action: self.action.execute
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//
// TPopUpModifier.swift
// DesignSystem
//
// Created by 박민서 on 1/16/25.
// Copyright © 2025 yapp25thTeamTnT. All rights reserved.
//

import SwiftUI

/// 팝업 컨테이너 (공통 레이아웃)
public struct TPopUpModifier<InnerContent: View>: ViewModifier {

/// 팝업 내부 콘텐츠의 기본 패딩
private let defaultInnerPadding: CGFloat = 20
/// 팝업 배경의 기본 불투명도
/// 0.0 = 완전 투명, 1.0 = 완전 불투명
private let defaultBackgroundOpacity: Double = 0.8
/// 팝업의 기본 배경 색상
private let defaultPopUpBackgroundColor: Color = .white
/// 팝업 모서리의 기본 곡률 (Corner Radius)
private let defaultCornerRadius: CGFloat = 16
/// 팝업 그림자의 기본 반경
private let defaultShadowRadius: CGFloat = 10
/// 팝업 콘텐츠의 기본 크기
private let defaultContentSize: CGSize = .init(width: 297, height: 175)

/// 팝업에 표시될 내부 콘텐츠 클로저
private let innerContent: () -> InnerContent
/// 팝업 표시 여부
@Binding private var isPresented: Bool

/// TPopupModifier 초기화 메서드
/// - Parameters:
/// - isPresented: 팝업 표시 여부를 제어하는 Binding
/// - newContent: 팝업에 표시될 내부 콘텐츠 클로저
public init(
isPresented: Binding<Bool>,
newContent: @escaping () -> InnerContent
) {
self._isPresented = isPresented
self.innerContent = newContent
}

public func body(content: Content) -> some View {
ZStack {
// 기존 뷰
content
.zIndex(0)

if isPresented {
// 반투명 배경
Color.black.opacity(defaultBackgroundOpacity)
.ignoresSafeArea()
.zIndex(1)
.onTapGesture {
isPresented = false
}

// 팝업 뷰
self.innerContent()
.frame(minWidth: defaultContentSize.width, minHeight: defaultContentSize.height)
.padding(defaultInnerPadding)
.background(defaultPopUpBackgroundColor)
.cornerRadius(defaultCornerRadius)
.shadow(radius: defaultShadowRadius)
.padding()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

padding 한번만 확인해주세요!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 부분 관련해서 확인한 결과, 현 스프린트 시점까지는 내부에 포함되는 컨텐츠 관련해서 -
alert 제외 동적으로 표시되는 화면이 없습니다. -> 해당 동적 컨텐츠 팝업 뷰의 고정 너비 or 외부 패딩 관련 정책이 없습니다.

현재 alert의 경우 내부 패딩을 적용한 상태로 고정 너비값을 가지므로 이를 메인으로 가져가면서,
추후 동적 컨텐츠이 포함된 디자인 확정 시 해당 정책 더블 체크해서 패딩값 바꿔놓겠습니다!

.zIndex(2)
}
}
.animation(.easeInOut, value: isPresented)
}
}

public extension View {
/// 팝업 표시를 위한 View Modifier
/// - Parameters:
/// - isPresented: 팝업 표시 여부를 제어하는 Binding
/// - content: 팝업 내부에 표시할 콘텐츠 클로저
/// - Returns: 팝업이 추가된 View
func tPopUp<Content: View>(
isPresented: Binding<Bool>,
@ViewBuilder content: @escaping () -> Content
) -> some View {
self.modifier(TPopUpModifier<Content>(isPresented: isPresented, newContent: content))
}

/// `TPopUp.Alert` 팝업 전용 View Modifier
/// - Parameters:
/// - isPresented: 팝업 표시 여부를 제어하는 Binding
/// - content: 팝업 알림 내용을 구성하는 클로저
/// - Returns: 팝업이 추가된 View
func tPopUp(isPresented: Binding<Bool>, content: @escaping () -> TPopUpAlertView) -> some View {
self.modifier(TPopUpModifier(isPresented: isPresented, newContent: content))
}
}
33 changes: 33 additions & 0 deletions TnT/Projects/DesignSystem/Sources/Utility/EquatableClosure.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// EquatableClosure.swift
// DesignSystem
//
// Created by 박민서 on 1/16/25.
// Copyright © 2025 yapp25thTeamTnT. All rights reserved.
//

import Foundation

/// 클로저를 Equatable로 사용할 수 있도록 래핑한 구조체입니다.
/// 고유 ID를 통해 클로저의 동등성을 비교하며, 내부에서 클로저를 실행할 수 있는 기능을 제공합니다.
public struct EquatableClosure: Equatable {
/// EquatableClosure를 고유하게 식별할 수 있는 UUID입니다.
private let id: UUID = UUID()
/// 실행할 클로저
private let action: () -> Void

public static func == (lhs: EquatableClosure, rhs: EquatableClosure) -> Bool {
lhs.id == rhs.id
}

/// EquatableClosure 초기화 메서드
/// - Parameter action: 실행할 클로저
public init(action: @escaping () -> Void) {
self.action = action
}

/// 클로저를 실행하는 메서드
public func execute() {
action()
}
}