-
Notifications
You must be signed in to change notification settings - Fork 1
[Feat-T3-70] 추천 루틴 UI 구현 #21
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
Changes from 14 commits
b06997d
9dd6c73
d98f0a1
9e6c5da
d3de835
ce75abe
4337020
a57d446
921cccc
c0a58ac
7fc083c
c2b6e84
7df9948
66526c9
6bf9281
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| { | ||
| "images" : [ | ||
| { | ||
| "filename" : "chevron_icon.png", | ||
| "idiom" : "universal", | ||
| "scale" : "1x" | ||
| }, | ||
| { | ||
| "filename" : "chevron_icon@2x.png", | ||
| "idiom" : "universal", | ||
| "scale" : "2x" | ||
| }, | ||
| { | ||
| "filename" : "chevron_icon@3x.png", | ||
| "idiom" : "universal", | ||
| "scale" : "3x" | ||
| } | ||
| ], | ||
| "info" : { | ||
| "author" : "xcode", | ||
| "version" : 1 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| { | ||
| "images" : [ | ||
| { | ||
| "filename" : "plus_icon.png", | ||
| "idiom" : "universal", | ||
| "scale" : "1x" | ||
| }, | ||
| { | ||
| "filename" : "plus_icon@2x.png", | ||
| "idiom" : "universal", | ||
| "scale" : "2x" | ||
| }, | ||
| { | ||
| "filename" : "plus_icon@3x.png", | ||
| "idiom" : "universal", | ||
| "scale" : "3x" | ||
| } | ||
| ], | ||
| "info" : { | ||
| "author" : "xcode", | ||
| "version" : 1 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| // | ||
| // CustomBottomSheet.swift | ||
| // Presentation | ||
| // | ||
| // Created by 최정인 on 7/14/25. | ||
| // | ||
|
|
||
| import SnapKit | ||
| import UIKit | ||
|
|
||
| final class CustomBottomSheet: UIViewController { | ||
|
|
||
| private enum Layout { | ||
| static let bottomSheetCornerRadius: CGFloat = 20 | ||
| static let dragHandleTopSpacing: CGFloat = 16 | ||
| static let dragHandleCornerRadius: CGFloat = 2 | ||
| static let dragHandleHeight: CGFloat = 4 | ||
| static let dragHandleWidth: CGFloat = 32 | ||
| static let contentViewTopSpacing: CGFloat = 16 | ||
| } | ||
|
|
||
| private let maxHeight: CGFloat | ||
| private let contentViewController: UIViewController | ||
|
|
||
| private let dimmedView = UIView() | ||
| private let bottomSheetView = UIView() | ||
| private let dragHandle = UIView() | ||
| private let contentView = UIView() | ||
| private var bottomSheetBottomConstraint: Constraint? | ||
|
|
||
| init(contentViewController: UIViewController, maxHeight: CGFloat = 300) { | ||
| self.contentViewController = contentViewController | ||
| self.maxHeight = maxHeight | ||
| super.init(nibName: nil, bundle: nil) | ||
|
|
||
| modalPresentationStyle = .overFullScreen | ||
| modalTransitionStyle = .crossDissolve | ||
| } | ||
|
|
||
| required init?(coder: NSCoder) { | ||
| fatalError("init(coder:) has not been implemented") | ||
| } | ||
|
|
||
| override func viewDidLoad() { | ||
| super.viewDidLoad() | ||
| configureAttribute() | ||
| configureLayout() | ||
| setupContent() | ||
| } | ||
|
|
||
| override func viewDidAppear(_ animated: Bool) { | ||
| super.viewDidAppear(animated) | ||
| showBottomSheet() | ||
| } | ||
|
|
||
| private func configureAttribute() { | ||
| dimmedView.do { | ||
| $0.backgroundColor = UIColor.black.withAlphaComponent(0.7) | ||
| $0.alpha = 0 | ||
| } | ||
|
|
||
| bottomSheetView.do { | ||
| $0.backgroundColor = UIColor.systemBackground | ||
| $0.layer.cornerRadius = Layout.bottomSheetCornerRadius | ||
| $0.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] | ||
| } | ||
|
|
||
| dragHandle.do { | ||
| $0.backgroundColor = UIColor.systemGray4 | ||
| $0.layer.cornerRadius = Layout.dragHandleCornerRadius | ||
| } | ||
|
|
||
| contentView.do { | ||
| $0.backgroundColor = .clear | ||
| } | ||
|
|
||
| let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissBottomSheet)) | ||
| dimmedView.addGestureRecognizer(tapGesture) | ||
|
|
||
| let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) | ||
| bottomSheetView.addGestureRecognizer(panGesture) | ||
| } | ||
|
|
||
| private func configureLayout() { | ||
| view.addSubview(dimmedView) | ||
| view.addSubview(bottomSheetView) | ||
| bottomSheetView.addSubview(dragHandle) | ||
| bottomSheetView.addSubview(contentView) | ||
|
|
||
| dimmedView.snp.makeConstraints { make in | ||
| make.edges.equalToSuperview() | ||
| } | ||
|
|
||
| bottomSheetView.snp.makeConstraints { make in | ||
| make.leading.trailing.equalToSuperview() | ||
| make.height.equalTo(maxHeight) | ||
| self.bottomSheetBottomConstraint = make.bottom.equalToSuperview().offset(maxHeight).constraint | ||
| } | ||
|
|
||
| dragHandle.snp.makeConstraints { make in | ||
| make.top.equalToSuperview().offset(Layout.dragHandleTopSpacing) | ||
| make.centerX.equalToSuperview() | ||
| make.width.equalTo(Layout.dragHandleWidth) | ||
| make.height.equalTo(Layout.dragHandleHeight) | ||
| } | ||
|
|
||
| contentView.snp.makeConstraints { make in | ||
| make.top.equalTo(dragHandle.snp.bottom).offset(Layout.contentViewTopSpacing) | ||
| make.leading.trailing.equalToSuperview() | ||
| make.bottom.equalToSuperview() | ||
| } | ||
| } | ||
|
|
||
| private func setupContent() { | ||
| addChild(contentViewController) | ||
| contentView.addSubview(contentViewController.view) | ||
| contentViewController.didMove(toParent: self) | ||
|
|
||
| contentViewController.view.snp.makeConstraints { make in | ||
| make.edges.equalToSuperview() | ||
| } | ||
| } | ||
|
|
||
| private func showBottomSheet() { | ||
| UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) { | ||
| self.dimmedView.alpha = 1 | ||
| self.bottomSheetBottomConstraint?.update(offset: 0) | ||
| self.view.layoutIfNeeded() | ||
| } | ||
| } | ||
|
|
||
| @objc private func dismissBottomSheet() { | ||
| UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) { | ||
| self.dimmedView.alpha = 0 | ||
| self.bottomSheetBottomConstraint?.update(offset: self.maxHeight) | ||
| self.view.layoutIfNeeded() | ||
| } completion: { _ in | ||
| self.dismiss(animated: false) | ||
| } | ||
| } | ||
|
|
||
| @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { | ||
| let translation = gesture.translation(in: view) | ||
| let velocity = gesture.velocity(in: view) | ||
|
|
||
| switch gesture.state { | ||
| case .changed: | ||
| let offset = max(0, translation.y) | ||
| bottomSheetBottomConstraint?.update(offset: offset) | ||
|
|
||
| let progress = min(1, offset / maxHeight) | ||
| dimmedView.alpha = 1 - progress | ||
|
|
||
| case .ended: | ||
| let shouldDismiss = translation.y > maxHeight * 0.3 || velocity.y > 1000 | ||
| if shouldDismiss { | ||
| dismissBottomSheet() | ||
| } else { | ||
| UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) { | ||
| self.bottomSheetBottomConstraint?.update(offset: 0) | ||
| self.dimmedView.alpha = 1 | ||
| self.view.layoutIfNeeded() | ||
| } | ||
| } | ||
|
|
||
| default: | ||
| break | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| // | ||
| // UIImage+.swift | ||
| // Presentation | ||
| // | ||
| // Created by 최정인 on 7/17/25. | ||
| // | ||
|
|
||
| import UIKit | ||
|
|
||
| extension UIImage { | ||
| // 받은 크기로 이미지 크기를 변경합니다. (원본 비율을 무시될 수 있습니다.) | ||
| func resize(to size: CGSize) -> UIImage? { | ||
| let renderer = UIGraphicsImageRenderer(size: size) | ||
| return renderer.image { _ in | ||
| draw(in: CGRect(origin: .zero, size: size)) | ||
| } | ||
| } | ||
|
|
||
| // 원본 비율 유지하면서 이미지 크기를 변경합니다. (aspect fit) | ||
| func resizeAspectFit(to size: CGSize) -> UIImage? { | ||
| let aspectRatio = self.size.width / self.size.height | ||
| let targetRatio = size.width / size.height | ||
|
|
||
| var newSize: CGSize | ||
| if aspectRatio > targetRatio { | ||
| newSize = CGSize(width: size.width, height: size.width / aspectRatio) | ||
| } else { | ||
| newSize = CGSize(width: size.height * aspectRatio, height: size.height) | ||
| } | ||
|
|
||
| let renderer = UIGraphicsImageRenderer(size: newSize) | ||
| return renderer.image { _ in | ||
| draw(in: CGRect(origin: .zero, size: newSize)) | ||
| } | ||
| } | ||
|
|
||
| // 원본 비율 유지하면서 이미지 크기를 변경합니다. (aspect fill) | ||
| func resizeAspectFill(to size: CGSize) -> UIImage? { | ||
| let aspectRatio = self.size.width / self.size.height | ||
| let targetRatio = size.width / size.height | ||
|
|
||
| var newSize: CGSize | ||
| if aspectRatio > targetRatio { | ||
| newSize = CGSize(width: size.height * aspectRatio, height: size.height) | ||
| } else { | ||
| newSize = CGSize(width: size.width, height: size.width / aspectRatio) | ||
| } | ||
|
|
||
| let renderer = UIGraphicsImageRenderer(size: newSize) | ||
| return renderer.image { _ in | ||
| draw(in: CGRect(origin: .zero, size: newSize)) | ||
| } | ||
| } | ||
|
|
||
| // 이미지를 회전합니다. | ||
| func rotate(radians: Float) -> UIImage? { | ||
| let rotatedSize = CGRect(origin: .zero, size: size) | ||
| .applying(CGAffineTransform(rotationAngle: CGFloat(radians))) | ||
| .integral.size | ||
|
|
||
| let renderer = UIGraphicsImageRenderer(size: rotatedSize) | ||
| return renderer.image { context in | ||
| context.cgContext.translateBy(x: rotatedSize.width / 2, y: rotatedSize.height / 2) | ||
| context.cgContext.rotate(by: CGFloat(radians)) | ||
| draw(in: CGRect(origin: CGPoint(x: -size.width / 2, y: -size.height / 2), size: size)) | ||
| } | ||
| } | ||
|
|
||
| func rotate(degrees: Float) -> UIImage? { | ||
| return rotate(radians: degrees * .pi / 180) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -38,8 +38,11 @@ final public class TabBarView: UITabBarController { | |||||||||||||||||
| tabBar.tintColor = BitnagilColor.navy600 | ||||||||||||||||||
| tabBar.unselectedItemTintColor = BitnagilColor.navy100 | ||||||||||||||||||
|
|
||||||||||||||||||
| guard let viewModel = DIContainer.shared.resolve(type: RecommendedRoutineViewModel.self) else { | ||||||||||||||||||
| fatalError("RecommendedViewModel 의존성이 등록되지 않았습니다.") | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+41
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 의존성 해결 실패 시 더 안전한 에러 처리 방식을 고려해보세요.
- guard let viewModel = DIContainer.shared.resolve(type: RecommendedRoutineViewModel.self) else {
- fatalError("RecommendedViewModel 의존성이 등록되지 않았습니다.")
- }
+ guard let viewModel = DIContainer.shared.resolve(type: RecommendedRoutineViewModel.self) else {
+ BitnagilLogger.log(logType: .error, message: "RecommendedRoutineViewModel 의존성이 등록되지 않았습니다.")
+ // 임시 처리: 기본 뷰 또는 에러 뷰 표시
+ return
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| let homeView = HomeView() | ||||||||||||||||||
| let recommendView = RecommendView() | ||||||||||||||||||
| let recommendView = RecommendedRoutineView(viewModel: viewModel) | ||||||||||||||||||
| let reportView = ReportView() | ||||||||||||||||||
| let mypageView = MypageView() | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
@@ -80,13 +83,6 @@ final class HomeView: UIViewController { | |||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| final class RecommendView: UIViewController { | ||||||||||||||||||
| override func viewDidLoad() { | ||||||||||||||||||
| super.viewDidLoad() | ||||||||||||||||||
| view.backgroundColor = .white | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| final class ReportView: UIViewController { | ||||||||||||||||||
| override func viewDidLoad() { | ||||||||||||||||||
| super.viewDidLoad() | ||||||||||||||||||
|
|
||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
고생하셨습니다!! 혹시 sheetPresentationController를 고려해보셨을까요?
따로 커스텀 바텀 시트를 VC를 구현하신 이유가 있는지 궁금합니다!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
엇 혹시 sheetPresentationController 이것도 MaxHeight를 설정할 수 있나요 ??
디자인 요구사항에서 딱 최대 높이 이상은 못올라가게 했으면 좋겠다는 의견이 있어서 CustomBottomSheet를 구현했습니다 !! ㅠㅠ
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
제가 알기론느 detent를 이용해서 최대 높이를 정해줄 수 있는것으로 알고 있습니다! 다만 iOS 버전에 따라 조금 다를 수 있을거 같아서 확인이 필요할 것 같네요!
그런 제약 조건이 있는걸 몰랐어요.!.! 커스텀 시트 구현해주셔서 오히려 좋아입니다~