From 8fbf47b82efe4b565cceae8a9a1d0eb4ae864c7a Mon Sep 17 00:00:00 2001 From: jam0128 <52900717+jam0128@users.noreply.github.com> Date: Mon, 13 Feb 2023 21:36:31 +0900 Subject: [PATCH] =?UTF-8?q?[Feature]=20=EB=B2=A0=EC=A7=80=EC=96=B4=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC,=20Boxing=20=EA=B4=80=EB=A0=A8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Feature] 베지어 버튼, Boxing 관련 기능 추가 * [Etc] 리뷰적용 --- .../Boxing/BezierCornerRadius.swift | 68 ++ .../Foundation/Boxing/BezierElevation.swift | 43 ++ .../Foundation/Color/SemanticColor.swift | 3 + .../MasterComponent/BezierButton.swift | 730 ++++++++++++++++++ 4 files changed, 844 insertions(+) create mode 100644 Sources/BezierSwift/Foundation/Boxing/BezierCornerRadius.swift create mode 100644 Sources/BezierSwift/Foundation/Boxing/BezierElevation.swift create mode 100644 Sources/BezierSwift/Foundation/MasterComponent/BezierButton.swift diff --git a/Sources/BezierSwift/Foundation/Boxing/BezierCornerRadius.swift b/Sources/BezierSwift/Foundation/Boxing/BezierCornerRadius.swift new file mode 100644 index 0000000..9507f32 --- /dev/null +++ b/Sources/BezierSwift/Foundation/Boxing/BezierCornerRadius.swift @@ -0,0 +1,68 @@ +// +// BezierCornerRadius.swift +// +// +// Created by Jam on 2023/02/09. +// + +import SwiftUI + +enum BezierCornerRadius { + case round2 + case round3 + case round4 + case round6 + case round8 + case round12 + case round16 + case round20 + case round22 + case round32 + case round44 + case roundHalf(length: CGFloat) + case roundAvatar(length: CGFloat) + + var rawValue: CGFloat { + switch self { + case .round2: + return CGFloat(2) + case .round3: + return CGFloat(3) + case .round4: + return CGFloat(4) + case .round6: + return CGFloat(6) + case .round8: + return CGFloat(8) + case .round12: + return CGFloat(12) + case .round16: + return CGFloat(16) + case .round20: + return CGFloat(20) + case .round22: + return CGFloat(22) + case .round32: + return CGFloat(32) + case .round44: + return CGFloat(44) + case .roundHalf(let length): + return length / CGFloat(2) + case .roundAvatar(let length): + return length * CGFloat(0.42) + } + } +} + + +extension View { + func applyBezierCornerRadius(type: BezierCornerRadius, correction: CGFloat = 0) -> some View { + return self + .clipShape( + RoundedRectangle( + cornerRadius: type.rawValue + correction, + style: .continuous + ) + ) + } +} diff --git a/Sources/BezierSwift/Foundation/Boxing/BezierElevation.swift b/Sources/BezierSwift/Foundation/Boxing/BezierElevation.swift new file mode 100644 index 0000000..70a7347 --- /dev/null +++ b/Sources/BezierSwift/Foundation/Boxing/BezierElevation.swift @@ -0,0 +1,43 @@ +// +// BezierElevation.swift +// +// +// Created by Jam on 2023/02/09. +// + +import SwiftUI + +enum BezierElevation { + case mEv1 + case mEv2 + case mEv3 + case mEv4 + case mEv5 + case mEv6 + + func rawValue( + _ themeable: Themeable + ) -> (color: Color, x: CGFloat, y: CGFloat, blur: CGFloat) { + switch self { + case .mEv1: return (themeable.palette(.shdwMedium), 0, 1, 4) + case .mEv2: return (themeable.palette(.shdwMedium), 0, 2, 6) + case .mEv3: return (themeable.palette(.shdwLarge), 0, 4, 20) + case .mEv4: return (themeable.palette(.shdwXlarge), 0, 4, 24) + case .mEv5: return (themeable.palette(.shdwXlarge), 0, 6, 40) + case .mEv6: return (themeable.palette(.shdwXlarge), 0, 12, 60) + } + } +} + +extension View { + func applyBezierElevation(_ themeable: Themeable, type: BezierElevation) -> some View { + let rawVaue = type.rawValue(themeable) + return self + .shadow( + color: rawVaue.color, + radius: rawVaue.blur, + x: rawVaue.x, + y: rawVaue.y + ) + } +} diff --git a/Sources/BezierSwift/Foundation/Color/SemanticColor.swift b/Sources/BezierSwift/Foundation/Color/SemanticColor.swift index bc97b7b..9259ef2 100644 --- a/Sources/BezierSwift/Foundation/Color/SemanticColor.swift +++ b/Sources/BezierSwift/Foundation/Color/SemanticColor.swift @@ -8,6 +8,8 @@ import SwiftUI public enum SemanticColor { + case bgTransparent + // MARK: - Background case bgWhiteHigh case bgWhiteLow @@ -155,6 +157,7 @@ extension SemanticColor { private var paletteSet: PaletteSet { switch self { + case .bgTransparent: return (Palette.white_0, Palette.white_0) // MARK: - Background case .bgWhiteHigh: return (Palette.white, Palette.grey700) case .bgWhiteLow: return (Palette.white, Palette.grey800) diff --git a/Sources/BezierSwift/Foundation/MasterComponent/BezierButton.swift b/Sources/BezierSwift/Foundation/MasterComponent/BezierButton.swift new file mode 100644 index 0000000..9c4eba5 --- /dev/null +++ b/Sources/BezierSwift/Foundation/MasterComponent/BezierButton.swift @@ -0,0 +1,730 @@ +// +// BezierButton.swift +// +// +// Created by Jam on 2023/02/09. +// + +import SwiftUI + +private enum Metric { + static let textLeadingTrailing = CGFloat(2) +} + +private enum Constant { + static let disalbedOpacity = CGFloat(0.4) +} + +public enum ButtonSize { + case xsmall + case small + case medium + case large + case xlarge + + var height: CGFloat { + switch self { + case .xsmall: + return 24 + case .small: + return 30 + case .medium: + return 40 + case .large: + return 44 + case .xlarge: + return 54 + } + } + + var stackSpacing: CGFloat { + switch self { + case .xsmall, .small, .medium: + return 4 + case .large: + return 5 + case .xlarge: + return 6 + } + } + + var imageLength: CGFloat { + switch self { + case .xsmall: + return 16 + case .small: + return 20 + case .medium, .large, .xlarge: + return 24 + } + } + + var font: Font { + self.bezierFont.font + } + + var bezierFont: BezierFont { + switch self { + case .xsmall: + return BezierFont.bold13 + case .small: + return BezierFont.bold14 + case .medium: + return BezierFont.bold15 + case .large, .xlarge: + return BezierFont.bold16 + } + } + + var minLeadingTrailingPadding: CGFloat { + switch self { + case .xsmall, .small: + return 6 + case .medium: + return 8 + case .large: + return 10 + case .xlarge: + return 12 + } + } + + var topBottomPadding: CGFloat { + return (self.height - self.imageLength) / CGFloat(2) + } + + func cornerRadius(type: ButtonType) -> BezierCornerRadius { + switch type { + case .primary, .secondary, .tertiary: + switch self { + case .xsmall: + return .round6 + case .small: + return .round8 + case .medium: + return .round12 + case .large: + return .round12 + case .xlarge: + return .round16 + } + case .floating: + return .roundHalf(length: self.height) + } + } +} + +public enum ButtonColor: String { + case blue + case red + case green + case yellow + case cobalt + case monochrome // TODO: 레거시. monochromeLight / Dark 논의 이후에 색상 치환할 것. + case monochromeLight + case monochromeDark + case absoulteWhite +} + +public enum ButtonType: Equatable { + case primary(ButtonColor) + case secondary(ButtonColor) + case tertiary(ButtonColor) + case floating(ButtonColor) + + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (let .primary(lhsColor), let .primary(rhsColor)): + return lhsColor == rhsColor + case (let .secondary(lhsColor), let .secondary(rhsColor)): + return lhsColor == rhsColor + case (let .tertiary(lhsColor), let .tertiary(rhsColor)): + return lhsColor == rhsColor + case (let .floating(lhsColor), let .floating(rhsColor)): + return lhsColor == rhsColor + default: + return false + } + } + + func textColor(_ size: ButtonSize) -> SemanticColor { + switch self { + case .primary: + return .bgtxtAbsoluteWhiteDark + case .secondary(let color), .tertiary(let color): + switch color { + case .blue: + return .bgtxtBlueNormal + case .red: + return .bgtxtRedNormal + case .green: + return .bgtxtGreenNormal + case .yellow: + return .bgtxtYellowNormal + case .cobalt: + return .bgtxtCobaltNormal + case .monochrome: + switch size { + case .xsmall, .small: + return .txtBlackDarker + case .medium, .large, .xlarge: + return .txtBlackDarkest + } + case .monochromeLight: + return .txtBlackDarker + case .monochromeDark: + return .txtBlackDarkest + case .absoulteWhite: + return .bgtxtAbsoluteWhiteDark + } + case .floating(let color): + switch color { + case .blue, .red, .green, .yellow, .cobalt, .absoulteWhite: + return .bgtxtAbsoluteWhiteDark + case .monochrome, .monochromeLight, .monochromeDark: + return .txtBlackDarkest + } + } + } + + func imageTintColor(_ size: ButtonSize) -> SemanticColor { + switch self { + case .primary: + return .bgtxtAbsoluteWhiteDark + case .secondary(let color), .tertiary(let color): + switch color { + case .blue: + return .bgtxtBlueNormal + case .red: + return .bgtxtRedNormal + case .green: + return .bgtxtGreenNormal + case .yellow: + return .bgtxtYellowNormal + case .cobalt: + return .bgtxtCobaltNormal + case .monochrome: + switch size { + case .xsmall, .small: + return .txtBlackDark + case .medium, .large, .xlarge: + return .txtBlackDarker + } + case .monochromeLight: + return .txtBlackDark + case .monochromeDark: + return .txtBlackDarker + case .absoulteWhite: + return .bgtxtAbsoluteWhiteDark + } + case .floating(let color): + switch color { + case .blue, .red, .green, .yellow, .cobalt, .absoulteWhite: + return .bgtxtAbsoluteWhiteDark + case .monochrome, .monochromeLight, .monochromeDark: + return .txtBlackDarker + } + } + } + + func backgroundColor(state: ButtonState) -> SemanticColor { + switch self { + case .primary(let color): + switch color { + case .blue: + switch state { + case .default, .disabled: + return .bgtxtBlueNormal + case .pressed: + return .bgtxtBlueDark + } + case .red: + switch state { + case .default, .disabled: + return .bgtxtRedNormal + case .pressed: + return .bgtxtRedDark + } + case .green: + switch state { + case .default, .disabled: + return .bgtxtGreenNormal + case .pressed: + return .bgtxtGreenDark + } + case .yellow: + switch state { + case .default, .disabled: + return .bgtxtYellowNormal + case .pressed: + return .bgtxtYellowDark + } + case .cobalt: + switch state { + case .default, .disabled: + return .bgtxtCobaltNormal + case .pressed: + return .bgtxtCobaltDark + } + case .monochrome: + switch state { + case .default, .disabled: + return .bgtxtAbsoluteBlackLightest + case .pressed: + return .bgtxtAbsoluteBlackLighter + } + case .monochromeLight, .monochromeDark: + return .bgBlackLighter + case .absoulteWhite: + return .bgTransparent + } + case .secondary(let color): + switch color { + case .blue: + switch state { + case .default, .disabled: + return .bgtxtBlueLightest + case .pressed: + return .bgtxtBlueLighter + } + case .red: + switch state { + case .default, .disabled: + return .bgtxtRedLightest + case .pressed: + return .bgtxtRedLighter + } + case .green: + switch state { + case .default, .disabled: + return .bgtxtGreenLightest + case .pressed: + return .bgtxtGreenLighter + } + case .yellow: + switch state { + case .default, .disabled: + return .bgtxtYellowLightest + case .pressed: + return .bgtxtYellowLighter + } + case .cobalt: + switch state { + case .default, .disabled: + return .bgtxtCobaltLightest + case .pressed: + return .bgtxtCobaltLighter + } + case .monochrome, .monochromeLight, .monochromeDark: + switch state { + case .default, .disabled: + return .bgBlackLighter + case .pressed: + return .bgBlackLight + } + case .absoulteWhite: + return .bgTransparent + } + case .tertiary(let color): + switch color { + case .blue: + switch state { + case .default, .disabled: + return .bgTransparent + case .pressed: + return .bgtxtBlueLightest + } + case .red: + switch state { + case .default, .disabled: + return .bgTransparent + case .pressed: + return .bgtxtRedLightest + } + case .green: + switch state { + case .default, .disabled: + return .bgTransparent + case .pressed: + return .bgtxtGreenLightest + } + case .yellow: + switch state { + case .default, .disabled: + return .bgTransparent + case .pressed: + return .bgtxtYellowLightest + } + case .cobalt: + switch state { + case .default, .disabled: + return .bgTransparent + case .pressed: + return .bgtxtCobaltLightest + } + case .monochrome, .monochromeLight, .monochromeDark: + switch state { + case .default, .disabled: + return .bgTransparent + case .pressed: + return .bgBlackLighter + } + case .absoulteWhite: + return .bgTransparent + } + case .floating(let color): + switch color { + case .blue: + switch state { + case .default, .disabled: + return .bgtxtBlueNormal + case .pressed: + return .bgtxtBlueDark + } + case .red: + switch state { + case .default, .disabled: + return .bgtxtRedNormal + case .pressed: + return .bgtxtRedDark + } + case .green: + switch state { + case .default, .disabled: + return .bgtxtGreenNormal + case .pressed: + return .bgtxtGreenDark + } + case .yellow: + switch state { + case .default, .disabled: + return .bgtxtYellowNormal + case .pressed: + return .bgtxtYellowDark + } + case .cobalt: + switch state { + case .default, .disabled: + return .bgtxtCobaltNormal + case .pressed: + return .bgtxtCobaltDark + } + case .monochrome, .monochromeLight, .monochromeDark: + switch state { + case .default, .disabled: + return .bgWhiteHigh + case .pressed: + return .bgWhiteLow + } + case .absoulteWhite: + return .bgTransparent + } + } + } +} + +enum ButtonState { + case `default` + case pressed + case disabled +} + +public enum ButtonResizing { + case hug + case fill +} + +public struct BezierButton: View, Themeable { + private var size: ButtonSize + private var type: ButtonType + private var resizing: ButtonResizing + + private let action: () -> Void + private let title: String? + private let leftImage: Image? + private let rightImage: Image? + + @Environment(\.colorScheme) public var colorScheme + + private init( + size: ButtonSize, + type: ButtonType, + resizing: ButtonResizing, + action: @escaping () -> Void, + title: String? = nil, + leftContent: Image? = nil, + rightContent: Image? = nil + ) { + self.size = size + self.type = type + self.resizing = resizing + self.action = action + self.title = title + self.leftImage = leftContent + self.rightImage = rightContent + } + + public init( + size: ButtonSize, + type: ButtonType, + resizing: ButtonResizing, + action: @escaping () -> Void, + title: @escaping () -> String, + leftImage: @escaping () -> Image, + rightImage: @escaping () -> Image + ) { + self.init( + size: size, + type: type, + resizing: resizing, + action: action, + title: title(), + leftContent: leftImage(), + rightContent: rightImage() + ) + } + + public init( + size: ButtonSize, + type: ButtonType, + resizing: ButtonResizing, + action: @escaping () -> Void, + title: @escaping () -> String, + leftImage: @escaping () -> Image + ) { + self.init( + size: size, + type: type, + resizing: resizing, + action: action, + title: title(), + leftContent: leftImage() + ) + } + + public init( + size: ButtonSize, + type: ButtonType, + resizing: ButtonResizing, + action: @escaping () -> Void, + title: @escaping () -> String, + rightImage: @escaping () -> Image + ) { + self.init( + size: size, + type: type, + resizing: resizing, + action: action, + title: title(), + rightContent: rightImage() + ) + } + + public init( + size: ButtonSize, + type: ButtonType, + resizing: ButtonResizing, + action: @escaping () -> Void, + leftImage: @escaping () -> Image + ) { + self.init( + size: size, + type: type, + resizing: resizing, + action: action, + leftContent: leftImage() + ) + } + + public init( + size: ButtonSize, + type: ButtonType, + resizing: ButtonResizing, + action: @escaping () -> Void, + title: @escaping () -> String + ) { + self.init( + size: size, + type: type, + resizing: resizing, + action: action, + title: title() + ) + } + + public var body: some View { + Button { + self.action() + } label: { + HStack(alignment: .center, spacing: self.size.stackSpacing) { + if self.resizing == .fill { + Spacer(minLength: 0) + } + + if let leftImage { + leftImage + .applyBaseImageStyle() + .foregroundColor(self.palette(self.type.imageTintColor(self.size))) + .frame(width: self.size.imageLength, height: self.size.imageLength) + } + + if let title { + Text(title) + .applyBezierFontStyle( + self.size.bezierFont, + semanticColor: self.type.textColor(self.size) + ) + .padding(.horizontal, Metric.textLeadingTrailing) + } + + if let rightImage { + rightImage + .applyBaseImageStyle() + .foregroundColor(self.palette(self.type.imageTintColor(self.size))) + .frame(width: self.size.imageLength, height: self.size.imageLength) + } + + if self.resizing == .fill { + Spacer(minLength: 0) + } + } + .padding(.horizontal, self.minLeadingTrailing) + } + .buttonStyle( + BezierButtonStyle( + self, + size: size, + type: type, + resizing: resizing + ) + ) + } + + private var minLeadingTrailing: CGFloat { + let isImageOnly = self.title.isNil + && ( + (self.leftImage.isNotNil || self.rightImage.isNil) + || (self.leftImage.isNil || self.rightImage.isNotNil) + ) + let minLeadingTrailing = isImageOnly + ? self.size.topBottomPadding : self.size.minLeadingTrailingPadding + + return minLeadingTrailing + } +} + +private extension Image { + func applyBaseImageStyle() -> some View { + self + .renderingMode(.template) + .resizable() + .scaledToFit() + } +} + +struct BezierButtonStyle: ButtonStyle { + private let themeable: Themeable + private let size: ButtonSize + private let type: ButtonType + private let resizing: ButtonResizing + + @Environment(\.isEnabled) var isEnabled: Bool + + init( + _ themeable: Themeable, + size: ButtonSize, + type: ButtonType, + resizing: ButtonResizing + ) { + self.themeable = themeable + self.size = size + self.type = type + self.resizing = resizing + } + + func makeBody(configuration: Configuration) -> some View { + let buttonState: ButtonState = self.getState(configuration: configuration, isEnabled: self.isEnabled) + configuration.label + .frame(height: self.size.height) + .disabled(!self.isEnabled) + .background(themeable.palette(self.type.backgroundColor(state: buttonState))) + .applyBezierCornerRadius(type: self.size.cornerRadius(type: self.type)) + .opacity(self.isEnabled ? 1 : Constant.disalbedOpacity) + } + + private func getState(configuration: Configuration, isEnabled: Bool) -> ButtonState { + return configuration.isPressed ? .pressed : isEnabled ? .default : .disabled + } +} + +struct BezierButton_Previews: PreviewProvider { + static var previews: some View { + VStack { + BezierButton(size: .large, type: .primary(.green), resizing: .fill) { + print("") + } title: { + "Get started" + } leftImage: { + Image(systemName: "trash") + } rightImage: { + Image(systemName: "trash") + } + + BezierButton(size: .large, type: .primary(.blue), resizing: .hug) { + print("") + } title: { + "Get started" + } leftImage: { + Image(systemName: "trash") + } rightImage: { + Image(systemName: "trash") + } + + BezierButton(size: .large, type: .primary(.red), resizing: .hug) { + print("") + } title: { + "Get started" + } leftImage: { + Image(systemName: "trash") + } rightImage: { + Image(systemName: "trash") + } + + BezierButton(size: .large, type: .secondary(.red), resizing: .hug) { + print("") + } title: { + "Get started" + } leftImage: { + Image(systemName: "trash") + } rightImage: { + Image(systemName: "trash") + } + + BezierButton(size: .large, type: .tertiary(.red), resizing: .hug) { + print("") + } title: { + "Get started" + } leftImage: { + Image(systemName: "trash") + } rightImage: { + Image(systemName: "trash") + } + + BezierButton(size: .large, type: .floating(.red), resizing: .hug) { + print("") + } title: { + "Get started" + } leftImage: { + Image(systemName: "trash") + } rightImage: { + Image(systemName: "trash") + } + + BezierButton(size: .large, type: .primary(.yellow), resizing: .hug) { + print("") + } leftImage: { + Image(systemName: "trash") + } + }.padding() + } +}