Skip to content
Open
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,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
}
}
Comment on lines +1 to +24
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

TooltipView 내부에서 UIBezierPath를 사용하여 직접 화살표를 그리고 있으므로, 이 TooltipArrowView 클래스는 현재 프로젝트 내에서 사용되지 않는 것으로 보입니다. 불필요한 파일은 제거하여 프로젝트 구조를 깔끔하게 유지하는 것이 좋습니다.

Original file line number Diff line number Diff line change
@@ -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() }
}
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

arrowInset 값(28)은 TooltipFactory.swift의 61라인에서도 동일하게 사용되고 있습니다. 두 값이 일치하지 않을 경우 화살표의 위치와 툴팁의 배치가 어긋나는 버그가 발생할 수 있습니다. Constants 열거형에 공통 상수로 정의하여 두 파일에서 공유하도록 수정하는 것이 안전합니다.


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)
Comment on lines +140 to +141
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

layoutSubviews는 뷰의 레이아웃이 결정될 때마다 반복적으로 호출됩니다. 호출될 때마다 기존 레이어를 찾아 제거하고 새로운 CAShapeLayer를 생성하여 삽입하는 방식은 메모리 및 CPU 자원을 낭비하게 됩니다. 클래스 프로퍼티로 CAShapeLayer를 하나 유지하고, drawBubble에서는 해당 레이어의 path만 업데이트하도록 개선해 주세요.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -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를 제외한 영역 선택시 키보드 제거
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import RxSwift
import SnapKit

@MainActor
public final class ToastFactory {
public enum ToastFactory {

// MARK: - Properties

Expand Down
Original file line number Diff line number Diff line change
@@ -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 }
}
Comment on lines +11 to +16
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

최상단 window를 찾는 로직이 ToastFactory.swift와 완전히 중복되어 구현되어 있습니다. 향후 윈도우 탐색 로직이 변경될 경우 두 곳을 모두 수정해야 하는 번거로움이 있으므로, UIWindow extension 등으로 분리하여 공통으로 사용하는 것이 좋습니다.


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
}
Comment on lines +99 to +105
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

show 메서드에서는 툴팁이 나타날 때 페이드 인 애니메이션이 적용되어 있지만, dismiss 시에는 즉시 제거되어 사용자 경험이 다소 부자연스러울 수 있습니다. 제거 시에도 페이드 아웃 애니메이션을 적용한 뒤 removeFromSuperview를 호출하는 것을 고려해 보세요.

}
Loading