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 2 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,12 @@
{
"images" : [
{
"filename" : "icn_toggle_selected.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "icn_toggle_unselected.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//
// TControlButton.swift
// DesignSystem
//
// Created by 박민서 on 1/15/25.
// Copyright © 2025 yapp25thTeamTnT. All rights reserved.
//

import SwiftUI

/// TnT 앱 내에서 전반적으로 사용되는 커스텀 컨트롤 버튼 컴포넌트입니다.
public struct TControlButton: View {
/// 버틉 탭 액션
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: {
isSelected.toggle()
tapAction()
Copy link
Member

Choose a reason for hiding this comment

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

보통 이런 select들이 api 쏘는값이 많을텐데
네트워크 실패하면 다시 값 변경하는 로직을 추가하실건가요?? 저는 이또한 파편이라 생각합니다...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

앗 저기에 toggle빼는 걸 깜빡했습니다..! 죄송합니다..ㅠㅠ
해당 부분은 tapAction()만 수행하며, 외부에서 변경되는 isSelected 값을 반영하는 것이 맞습니다!

  • 해당 컴포넌트에서는 Reducer의 State만을 반영하여 UI에 표시하고,
  • tap하는 경우 action을 호출하여 Reducer의 State를 변경 -> UI 반영
    의 흐름으로 생각하며 작성했습니다

}, label: {
type.image(isSelected: isSelected)
.resizable()
.scaledToFit()
.frame(width: type.defaultSize.width, height: type.defaultSize.height)
})
}
}

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

Choose a reason for hiding this comment

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

저는 여기에 토글이 왜 껴있는지 잘 모르겠네요

Copy link
Contributor Author

@FpRaArNkK FpRaArNkK Jan 15, 2025

Choose a reason for hiding this comment

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

위에서 멘션주신 부분에 답변드린 것과 같은 맥락으로,
SwiftUI에서 기본적으로 제공하는 Toggle(isOn: Binding<Bool>)의 경우
Toggle 내부에서 바인딩으로 넘겨준 isOn의 값을 토글하는 문제가 발생했습니다.
말씀주셨던 API 호출 문제도 있고, Reducer의 로직 반영도 이루어지지 않는 UI처리라서 고민하다가 버튼으로 변경했습니다.
하지만 사용하는 입장에서 익숙한가..는 잘 모르겠습니다.. 분리해서 별도 구현하는 것이 좋을까요?

Copy link
Member

@stealmh stealmh Jan 15, 2025

Choose a reason for hiding this comment

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

저라면 분리할 것 같아요.
원래 바인딩해서 쓰게끔 하는 애를 액션을 만들어 쓴다 라는 것이 어색해서요!

한번 작성해주신 스타일 열거형이 꼭 액션이 필요한게 무엇이 있을까, 바인딩에 의존하고 있는 것이 무엇일까 잘 판단하고 분리할 지 정해주세요 😃

Copy link
Contributor Author

Choose a reason for hiding this comment

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

이마를 탁 때리게 되네요.. 더 고민하겠습니다!


/// 선택 상태에 따른 이미지 반환
func image(isSelected: Bool) -> Image {
switch self {

Copy link
Member

Choose a reason for hiding this comment

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

불필요한 띄어쓰기!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

아앗...!! 수정하겠습니다 감사합니다!

case .radio:
return Image(isSelected ? .icnRadioButtonSelected : .icnRadioButtonUnselected)
case .checkMark:
return Image(isSelected ? .icnCheckMarkFilled : .icnCheckMarkEmpty)
case .checkbox:
return Image(isSelected ? .icnCheckButtonSelected : .icnCheckButtonUnselected)
case .toggle:
return Image(isSelected ? .icnToggleSelected : .icnToggleUnselected)
case .star:
return Image(isSelected ? .icnStarFilled : .icnStarEmpty)
case .heart:
return Image(isSelected ? .icnHeartFilled : .icnHeartEmpty)
}
}

/// 스타일에 따른 기본 크기
var defaultSize: CGSize {
switch self {
case .toggle:
return .init(width: 44, height: 24)
default:
return .init(width: 24, height: 24)
}
}
}
}
189 changes: 189 additions & 0 deletions TnT/Projects/DesignSystem/Sources/Components/PopUp/TPopUp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
//
// TPopUp.swift
// DesignSystem
//
// Created by 박민서 on 1/15/25.
// Copyright © 2025 yapp25thTeamTnT. All rights reserved.
//

import SwiftUI

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

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

/// 팝업에 표시될 내부 콘텐츠 클로저
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: TPopUp.defaultContentSize.width, minHeight: TPopUp.defaultContentSize.height)
.padding(defaultInnerPadding)
.background(defaultPopUpBackgroundColor)
.cornerRadius(defaultCornerRadius)
.shadow(radius: 10)
.padding()
.zIndex(2)
}
}
.animation(.bouncy, value: isPresented)
}
}
}

extension TPopUp {
/// 팝업 Alert컨텐츠
public struct Alert: View {
/// 팝업 제목
private let title: String
/// 팝업 메시지
private let message: String
/// 팝업 버튼 배열
private let buttons: [TPopUp.ButtonContent]

/// TPopUpAlert 초기화 메서드
/// - Parameters:
/// - title: 팝업 제목
/// - message: 팝업 메시지
/// - buttons: 팝업 버튼 배열
public init(title: String, message: String, buttons: [TPopUp.ButtonContent]) {
self.title = title
self.message = message
self.buttons = buttons
}

public var body: some View {
VStack(spacing: 20) {
// 텍스트 Section
VStack(spacing: 8) {
Text(title)
.typographyStyle(.heading4, with: .neutral900)
.multilineTextAlignment(.center)
.padding(.top, 20)

Text(message)
.typographyStyle(.body2Medium, with: .neutral500)
.multilineTextAlignment(.center)
}

// 버튼 Section
HStack {
// TODO: 버튼 컴포넌트 완성시 대체
ForEach(buttons, id: \.title) { button in
Button(action: button.action) {
Text(button.title)
.typographyStyle(.body1Medium, with: button.style == .primary ? Color.neutral50 : Color.neutral500)
.padding()
.frame(maxWidth: .infinity)
.background(button.style == .primary ? Color.neutral900 : Color.neutral100)
.foregroundColor(.white)
.cornerRadius(8)
}
}
}
}
}
}
}

// TODO: 버튼 컴포넌트 완성시 대체
extension TPopUp {
/// 팝업 버튼 스타일
public struct ButtonContent {
/// 버튼 제목
let title: String
/// 버튼 스타일
let style: Style
/// 버튼 클릭 시 동작
let action: () -> Void

public enum Style {
case primary
case secondary
}

/// TPopUpButtonContent 초기화 메서드
/// - Parameters:
/// - title: 버튼 제목
/// - style: 버튼 스타일 (기본값: `.primary`)
/// - action: 버튼 클릭 시 동작
public init(
title: String,
style: Style = .primary,
action: @escaping () -> Void = {}
) {
self.title = title
self.action = action
self.style = style
}
}
}

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(TPopUp.Modifier<Content>(isPresented: isPresented, newContent: content))
}

/// `TPopUp.Alert` 팝업 전용 View Modifier
/// - Parameters:
/// - isPresented: 팝업 표시 여부를 제어하는 Binding
/// - content: 팝업 알림 내용을 구성하는 클로저
/// - Returns: 팝업이 추가된 View
func tPopUp(isPresented: Binding<Bool>, content: @escaping () -> TPopUp.Alert) -> some View {
self.modifier(TPopUp.Modifier(isPresented: isPresented, newContent: content))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ public extension ImageResource {
static let icnStarFilled: ImageResource = DesignSystemAsset.icnStarFilled.imageResource
static let icnHeartEmpty: ImageResource = DesignSystemAsset.icnHeartEmpty.imageResource
static let icnHeartFilled: ImageResource = DesignSystemAsset.icnHeartFilled.imageResource
static let icnToggleUnselected: ImageResource = DesignSystemAsset.icnToggleUnselected.imageResource
static let icnToggleSelected: ImageResource = DesignSystemAsset.icnToggleSelected.imageResource
}

// MARK: Image
Expand Down