diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tooltip/TooltipArrowView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tooltip/TooltipArrowView.swift new file mode 100644 index 00000000..34afa4ff --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tooltip/TooltipArrowView.swift @@ -0,0 +1,24 @@ +import UIKit + +import SnapKit + +final class TooltipArrowView: UIView { + + override class var layerClass: AnyClass { + CAShapeLayer.self + } + + override func layoutSubviews() { + super.layoutSubviews() + + let path = UIBezierPath() + path.move(to: CGPoint(x: bounds.midX, y: bounds.maxY)) + path.addLine(to: CGPoint(x: bounds.minX, y: bounds.minY)) + path.addLine(to: CGPoint(x: bounds.maxX, y: bounds.minY)) + path.close() + + let shape = layer as! CAShapeLayer + shape.path = path.cgPath + shape.fillColor = UIColor.whiteMLS.cgColor + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tooltip/TooltipOverlayView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tooltip/TooltipOverlayView.swift new file mode 100644 index 00000000..a319bfe0 --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tooltip/TooltipOverlayView.swift @@ -0,0 +1,29 @@ +import UIKit + +import RxCocoa +import RxSwift + +final class TooltipOverlayView: UIView { + // MARK: - Properties + private let disposeBag = DisposeBag() + + var onDismiss: (() -> Void)? + + // MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + + let tapGesture = UITapGestureRecognizer() + addGestureRecognizer(tapGesture) + + tapGesture.rx.event + .bind { [weak self] _ in + self?.onDismiss?() + } + .disposed(by: disposeBag) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tooltip/TooltipView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tooltip/TooltipView.swift new file mode 100644 index 00000000..5c4ec86b --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tooltip/TooltipView.swift @@ -0,0 +1,143 @@ +import UIKit + +import SnapKit + +/// Tooltip이 뻗어나가는 방향 +public enum TooltipPosition { + case topLeading + case topTrailing + case bottomLeading + case bottomTrailing +} + +private enum Constants { + static let arrowSize = CGSize(width: 16, height: 10) + static let cornerRadius: CGFloat = 16 +} + +final class TooltipView: UIView { + + // MARK: - Properties + private let tooltipPosition: TooltipPosition + + // MARK: - Components + private let label = UILabel() + + // MARK: - init + init(text: String, tooltipPosition: TooltipPosition) { + self.tooltipPosition = tooltipPosition + super.init(frame: .zero) + addViews() + setupConstraints() + configureUI(text: text) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - Layout +private extension TooltipView { + + func addViews() { + addSubview(label) + } + + func setupConstraints() { + switch tooltipPosition { + + /// 툴팁이 아래에 위치 + case .bottomLeading, .bottomTrailing: + label.snp.makeConstraints { make in + make.top.equalToSuperview().inset(11) + make.horizontalEdges.equalToSuperview().inset(18) + make.bottom.equalToSuperview().inset(11 + Constants.arrowSize.height) + } + + /// 툴팁이 위에 위치 + case .topLeading, .topTrailing: + label.snp.makeConstraints { make in + make.top.equalToSuperview().inset(11 + Constants.arrowSize.height) + make.horizontalEdges.equalToSuperview().inset(18) + make.bottom.equalToSuperview().inset(11) + } + } + } + + func configureUI(text: String) { + backgroundColor = .clear + + label.attributedText = .makeStyledString(font: .b_s_r, text: text) + label.numberOfLines = 0 + } +} + +// MARK: - Draw Bubble +extension TooltipView { + + override func layoutSubviews() { + super.layoutSubviews() + drawBubble() + } + + /// 말풍선 + arrow path 생성 + private func drawBubble() { + + let rect = bounds + let arrowHeight = Constants.arrowSize.height + let arrowWidth = Constants.arrowSize.width + + /// 툴팁 상하 위치 판단 + let isTop = tooltipPosition == .bottomLeading || tooltipPosition == .bottomTrailing + + /// 실제 툴팁 영역 + let bubbleRect = CGRect( + x: 0, + y: isTop ? 0 : arrowHeight, + width: rect.width, + height: rect.height - arrowHeight + ) + + let bubblePath = UIBezierPath( + roundedRect: bubbleRect, + cornerRadius: Constants.cornerRadius + ) + + /// arrow는 툴팁 내부에서 고정 위치 + let arrowInset: CGFloat = 28 + + let arrowX: CGFloat + switch tooltipPosition { + case .topLeading, .bottomLeading: + arrowX = arrowInset + + case .topTrailing, .bottomTrailing: + arrowX = bubbleRect.width - arrowInset + } + + let arrowPath = UIBezierPath() + + if isTop { + arrowPath.move(to: CGPoint(x: arrowX - arrowWidth/2, y: bubbleRect.minY)) + arrowPath.addLine(to: CGPoint(x: arrowX, y: bubbleRect.minY - arrowHeight)) + arrowPath.addLine(to: CGPoint(x: arrowX + arrowWidth/2, y: bubbleRect.minY)) + } else { + arrowPath.move(to: CGPoint(x: arrowX - arrowWidth/2, y: bubbleRect.maxY)) + arrowPath.addLine(to: CGPoint(x: arrowX, y: bubbleRect.maxY + arrowHeight)) + arrowPath.addLine(to: CGPoint(x: arrowX + arrowWidth/2, y: bubbleRect.maxY)) + } + + arrowPath.close() + bubblePath.append(arrowPath) + + let shapeLayer = CAShapeLayer() + shapeLayer.path = bubblePath.cgPath + shapeLayer.fillColor = UIColor.whiteMLS.cgColor + + /// 기존 shapeLayer 제거 후 다시 추가 + layer.sublayers?.removeAll(where: { $0 is CAShapeLayer }) + layer.insertSublayer(shapeLayer, at: 0) + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CharacterInputView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CharacterInputView.swift index d0d2d557..b69613e0 100644 --- a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CharacterInputView.swift +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/CharacterInputView.swift @@ -4,6 +4,20 @@ import RxCocoa import RxSwift import SnapKit +public enum CharacterViewType { + case normal + case recommend + + var title: String { + switch self { + case .normal: + "현재 레벨과 직업을\n입력해주세요." + case .recommend: + "사냥터 추천을 위해\n현재 레벨과 직업을 입력해주세요." + } + } +} + open class CharacterInputView: UIView { // MARK: - Type public enum Constant { @@ -40,11 +54,11 @@ open class CharacterInputView: UIView { public let nextButton = CommonButton(style: .normal, title: "다음", disabledTitle: "다음") // MARK: - init - public init(title: String? = nil) { + public init(type: CharacterViewType = .normal) { super.init(frame: .zero) addViews() setupConstraints() - configureUI(title: title) + configureUI(type: type) setGesture() } @@ -89,11 +103,11 @@ private extension CharacterInputView { } } - func configureUI(title: String? = nil) { + func configureUI(type: CharacterViewType) { inputBox.textField.delegate = self errorMessage.isHidden = true - descriptionLabel.attributedText = .makeStyledString(font: .h_xxl_b, text: title ?? "현재 레벨과 직업을\n입력해주세요.", alignment: .left) + descriptionLabel.attributedText = .makeStyledString(font: .h_xxl_b, text: type.title, alignment: .left) } /// inputBox를 제외한 영역 선택시 키보드 제거 diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/ToastFactory.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/ToastFactory.swift index 849298ba..096afadd 100644 --- a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/ToastFactory.swift +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/ToastFactory.swift @@ -4,7 +4,7 @@ import RxSwift import SnapKit @MainActor -public final class ToastFactory { +public enum ToastFactory { // MARK: - Properties diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/TooltipFactory.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/TooltipFactory.swift new file mode 100644 index 00000000..667921ba --- /dev/null +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/TooltipFactory.swift @@ -0,0 +1,106 @@ +import UIKit + +import RxSwift +import SnapKit + +@MainActor +public enum TooltipFactory { + // MARK: - Properties + + /// 현재 디바이스 최상단 Window를 지정 + static var window: UIWindow? { + UIApplication.shared + .connectedScenes + .flatMap { ($0 as? UIWindowScene)?.windows ?? [] } + .first { $0.isKeyWindow } + } + + private static var currentTooltip: TooltipView? + + /// 전체 터치 dismiss용 overlay + private static var overlayView: TooltipOverlayView? +} + +public extension TooltipFactory { + + /// Tooltip 노출 메소드 + static func show( + text: String, + anchorView: UIView, + tooltipPosition: TooltipPosition + ) { + currentTooltip?.removeFromSuperview() + currentTooltip = nil + + guard let window = window else { return } + + /// 전체 영역 터치 dismiss overlay + let overlay = TooltipOverlayView(frame: window.bounds) + window.addSubview(overlay) + overlayView = overlay + + let tooltip = TooltipView(text: text, tooltipPosition: tooltipPosition) + overlay.addSubview(tooltip) + currentTooltip = tooltip + + overlay.onDismiss = { + dismiss() + } + + let frame = anchorView.convert(anchorView.bounds, to: window) + + tooltip.frame.origin = CGPoint(x: 0, y: 0) + tooltip.setNeedsLayout() + tooltip.layoutIfNeeded() + + let tooltipSize = tooltip.systemLayoutSizeFitting( + UIView.layoutFittingCompressedSize + ) + + /// arrow가 툴팁 내부에서 위치하는 고정 inset + let arrowInset: CGFloat = 28 + let anchorCenterX = frame.midX + + /// 툴팁 내부 arrow 중심 위치 + let arrowCenterInTooltip: CGFloat + switch tooltipPosition { + case .topLeading, .bottomLeading: + arrowCenterInTooltip = arrowInset + + case .topTrailing, .bottomTrailing: + arrowCenterInTooltip = tooltipSize.width - arrowInset + } + + /// arrow 중심 = 버튼 중심 + let x = anchorCenterX - arrowCenterInTooltip + + let y: CGFloat + switch tooltipPosition { + case .topLeading, .topTrailing: + y = frame.minY - tooltipSize.height - 8 + case .bottomLeading, .bottomTrailing: + y = frame.maxY + 8 + } + + tooltip.frame = CGRect( + x: x, + y: y, + width: tooltipSize.width, + height: tooltipSize.height + ) + + tooltip.alpha = 0 + UIView.animate(withDuration: 0.25) { + tooltip.alpha = 1 + } + } + + /// 툴팁 제거 + static func dismiss() { + currentTooltip?.removeFromSuperview() + currentTooltip = nil + + overlayView?.removeFromSuperview() + overlayView = nil + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/ToLoginView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/ToLoginView.swift index f2914040..3cad3a23 100644 --- a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/ToLoginView.swift +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/ToLoginView.swift @@ -2,6 +2,29 @@ import UIKit import SnapKit +public enum LoginViewType { + case bookmark + case recommend + + var mainText: String { + switch self { + case .bookmark: + "북마크는 로그인 후 이용 가능해요!" + case .recommend: + "로그인하면 추천 기능이 열려요!" + } + } + + var subText: String { + switch self { + case .bookmark: + "자주 보는 정보, 검색 없이 바로 확인 할 수 있어요" + case .recommend: + "내 레벨과 직업에 맞춰\n사냥터를 추천받을 수 있어요" + } + } +} + public final class ToLoginView: UIView { // MARK: - Type enum Constant { @@ -19,11 +42,11 @@ public final class ToLoginView: UIView { public let button = CommonButton() // MARK: - Init - public init(mainText: String, subText: String, buttonText: String? = nil) { + public init(type: LoginViewType) { super.init(frame: .zero) addViews() setupConstraints() - configureUI(mainText: mainText, subText: subText, buttonText: buttonText) + configureUI(type: type) } @available(*, unavailable) @@ -65,20 +88,20 @@ private extension ToLoginView { } } - func configureUI(mainText: String, subText: String, buttonText: String? = nil) { + func configureUI(type: LoginViewType) { backgroundColor = .neutral100 imageView.image = DesignSystemAsset.image(named: "noShowList") mainLabel.attributedText = .makeStyledString( font: .h_xl_b, - text: mainText + text: type.mainText ) subLabel.attributedText = .makeStyledString( font: .cp_s_r, - text: subText, + text: type.subText, color: .neutral600 ) - button.updateTitle(title: buttonText ?? "로그인하러 가기") + button.updateTitle(title: "로그인하러 가기") } } diff --git a/MLS/MLSDesignSystemExample/ComponentsTest/TooltipTestViewController.swift b/MLS/MLSDesignSystemExample/ComponentsTest/TooltipTestViewController.swift new file mode 100644 index 00000000..48bb3a51 --- /dev/null +++ b/MLS/MLSDesignSystemExample/ComponentsTest/TooltipTestViewController.swift @@ -0,0 +1,123 @@ +import UIKit + +import MLSDesignSystem + +import RxCocoa +import RxSwift +import SnapKit + +final class TooltipTestViewController: UIViewController { + + // MARK: - Properties + var disposeBag = DisposeBag() + + let button1 = { + let button = UIButton(type: .system) + button.setTitle("우상단 버튼입니다", for: .normal) + return button + }() + + let button2 = { + let button = UIButton(type: .system) + button.setTitle("좌상단", for: .normal) + return button + }() + + let button3 = { + let button = UIButton(type: .system) + button.setTitle("우하단", for: .normal) + return button + }() + + let button4 = { + let button = UIButton(type: .system) + button.setTitle("좌하단", for: .normal) + return button + }() + + init() { + super.init(nibName: nil, bundle: nil) + self.title = "툴팁" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Life Cycle +extension TooltipTestViewController { + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .black + addViews() + setupConstraints() + bind() + } +} + +// MARK: - SetUp +private extension TooltipTestViewController { + func addViews() { + view.addSubview(button1) + view.addSubview(button2) + view.addSubview(button3) + view.addSubview(button4) + } + + func setupConstraints() { + button1.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + button2.snp.makeConstraints { make in + make.top.equalTo(button1.snp.bottom).offset(10) + make.centerX.equalToSuperview() + } + + button3.snp.makeConstraints { make in + make.top.equalTo(button2.snp.bottom).offset(10) + make.centerX.equalToSuperview() + } + + button4.snp.makeConstraints { make in + make.top.equalTo(button3.snp.bottom).offset(10) + make.centerX.equalToSuperview() + } + } + + func bind() { + button1.rx.tap + .withUnretained(self) + .subscribe { owner, _ in + TooltipFactory.show(text: "같은 레벨·직업 유저들이 자주 언급한\n사냥터를 기반으로 추천해요.", anchorView: owner.button1, tooltipPosition: .topLeading + ) + } + .disposed(by: disposeBag) + + button2.rx.tap + .withUnretained(self) + .subscribe { owner, _ in + TooltipFactory.show(text: "같은 레벨·직업 유저들이 자주 언급한\n사냥터를 기반으로 추천해요.", anchorView: owner.button2, tooltipPosition: .topTrailing + ) + } + .disposed(by: disposeBag) + + button3.rx.tap + .withUnretained(self) + .subscribe { owner, _ in + TooltipFactory.show(text: "같은 레벨·직업 유저들이 자주 언급한\n사냥터를 기반으로 추천해요.", anchorView: owner.button3, tooltipPosition: .bottomLeading + ) + } + .disposed(by: disposeBag) + + button4.rx.tap + .withUnretained(self) + .subscribe { owner, _ in + TooltipFactory.show(text: "같은 레벨·직업 유저들이 자주 언급한\n사냥터를 기반으로 추천해요.", anchorView: owner.button4, tooltipPosition: .bottomTrailing + ) + } + .disposed(by: disposeBag) + } +} diff --git a/MLS/MLSDesignSystemExample/ViewController.swift b/MLS/MLSDesignSystemExample/ViewController.swift index 7651950d..0c211648 100644 --- a/MLS/MLSDesignSystemExample/ViewController.swift +++ b/MLS/MLSDesignSystemExample/ViewController.swift @@ -39,7 +39,8 @@ class ViewController: UIViewController { SnackBarTestViewController(), BadgeTestController(), DictionaryDetailViewTestController(), - TextButtonTestViewController() + TextButtonTestViewController(), + TooltipTestViewController() ] override func viewDidLoad() {