Skip to content

Commit 75f4676

Browse files
design: 분실물 수정 UI 구현
1 parent 1878af5 commit 75f4676

19 files changed

Lines changed: 1345 additions & 63 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
//
2+
// ExtendedTouchAreaView.swift
3+
// koin
4+
//
5+
// Created by 홍기정 on 1/23/26.
6+
//
7+
8+
import UIKit
9+
10+
/// SuperView(ExtendedTouchAreaView)의 바깥 영역에 위치한 Subview의 터치 이벤트가 작동하도록 합니다.
11+
class ExtendedTouchAreaView: UIView {
12+
13+
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
14+
if super.point(inside: point, with: event) {
15+
return true
16+
}
17+
18+
for subview in subviews {
19+
let pointInSubview = subview.convert(point, from: self)
20+
if !subview.isHidden
21+
&& subview.isUserInteractionEnabled
22+
&& subview.point(inside: pointInSubview, with: event) {
23+
return true
24+
}
25+
}
26+
return false
27+
}
28+
}

Koin/Data/DTOs/Encodable/LostItem/UpdateLostItemRequest.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import Foundation
99

1010
struct UpdateLostItemRequest {
1111
let category: String
12-
let foundPlace: String
12+
let foundPlace: String?
1313
let foundDate: String
1414
let content: String?
1515
let newImages: [String]

Koin/Domain/Model/LostItem/LostItemData.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ struct LostItemData {
1313
let boardID: Int
1414
let type: LostItemType
1515
let category: String
16-
let foundPlace: String
16+
let foundPlace: String?
1717
let foundDate: String
1818
let content: String?
1919
let author: String
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
//
2+
// EditLostItemViewController.swift
3+
// koin
4+
//
5+
// Created by 홍기정 on 1/22/26.
6+
//
7+
8+
import Combine
9+
import PhotosUI
10+
import UIKit
11+
12+
final class EditLostItemViewController: UIViewController {
13+
14+
// MARK: - Properties
15+
private let viewModel: EditLostItemViewModel
16+
private let inputSubject = PassthroughSubject<EditLostItemViewModel.Input, Never>()
17+
private var subscriptions: Set<AnyCancellable> = []
18+
19+
// MARK: - UI Components
20+
private let scrollView = UIScrollView()
21+
22+
private let scrollContentView = UIView()
23+
24+
private lazy var headerView = EditLostItemHeaderView(type: viewModel.lostItemData.type)
25+
26+
private let topSeparateView = UIView().then {
27+
$0.backgroundColor = UIColor.appColor(.neutral100)
28+
}
29+
30+
private lazy var imagesView = EditLostItemImagesView(type: viewModel.lostItemData.type, images: viewModel.lostItemData.images)
31+
32+
private lazy var categoryView = EditLostItemCategoryView(selectedCategory: viewModel.lostItemData.category)
33+
34+
private lazy var foundDateView = EditLostItemFoundDateView(type: viewModel.lostItemData.type, foundDate: viewModel.lostItemData.foundDate)
35+
36+
private lazy var foundPlaceView = EditLostItemFoundPlaceView(type: viewModel.lostItemData.type, foundPlace: viewModel.lostItemData.foundPlace)
37+
38+
private lazy var contentView = EditLostItemContentView(type: viewModel.lostItemData.type, content: viewModel.lostItemData.content)
39+
40+
private let bottomSeparateView = UIScrollView().then {
41+
$0.backgroundColor = UIColor.appColor(.neutral100)
42+
}
43+
44+
private let editButton = DebouncedButton().then {
45+
$0.setTitle("수정 완료", for: .normal)
46+
$0.titleLabel?.font = UIFont.appFont(.pretendardBold, size: 14)
47+
$0.backgroundColor = UIColor.appColor(.primary600)
48+
$0.layer.cornerRadius = 8
49+
$0.layer.masksToBounds = true
50+
}
51+
52+
// MARK: - Initializer
53+
init(viewModel: EditLostItemViewModel) {
54+
self.viewModel = viewModel
55+
super.init(nibName: nil, bundle: nil)
56+
}
57+
required init?(coder: NSCoder) {
58+
fatalError("init(coder:) has not been implemented")
59+
}
60+
61+
deinit {
62+
NotificationCenter.default.removeObserver(self)
63+
}
64+
65+
// MARK: - Life Cycle
66+
override func viewDidLoad() {
67+
super.viewDidLoad()
68+
title = (viewModel.lostItemData.type == .lost ? "분실물 신고" : "습득물 신고" )
69+
configureView()
70+
bind()
71+
setAddTarget()
72+
hideKeyboardWhenTappedAround()
73+
addObserver()
74+
}
75+
76+
override func viewWillAppear(_ animated: Bool) {
77+
super.viewWillAppear(true)
78+
configureNavigationBar(style: .empty)
79+
}
80+
81+
private func bind() {
82+
viewModel.transform(with: inputSubject.eraseToAnyPublisher()).sink { [weak self] output in
83+
guard let self else { return }
84+
switch output {
85+
case .addImageUrl(let url):
86+
imagesView.imageUploadCollectionView.addImageUrl(url)
87+
case .showToast(let message):
88+
showToast(message: message)
89+
}
90+
}.store(in: &subscriptions)
91+
92+
imagesView.addImageButtonPublisher.sink { [weak self] in
93+
self?.addImageButtonTapped()
94+
}.store(in: &subscriptions)
95+
96+
imagesView.dismissDropDownPublisher.sink { [weak self] in
97+
self?.foundDateView.dismissDropdown()
98+
}.store(in: &subscriptions)
99+
100+
categoryView.dismissDropDownPublisher.sink { [weak self] in
101+
self?.foundDateView.dismissDropdown()
102+
}.store(in: &subscriptions)
103+
104+
foundPlaceView.shouldDismissDropDownPublisher.sink { [weak self] in
105+
self?.foundDateView.dismissDropdown()
106+
}.store(in: &subscriptions)
107+
108+
contentView.shouldDismissDropDownPublisher.sink { [weak self] in
109+
self?.foundDateView.dismissDropdown()
110+
}.store(in: &subscriptions)
111+
}
112+
}
113+
114+
extension EditLostItemViewController {
115+
116+
private func setAddTarget() {
117+
editButton.addTarget(self, action: #selector(editButtonTapped), for: .touchUpInside)
118+
}
119+
120+
@objc private func editButtonTapped() {
121+
if categoryView.isValid && foundDateView.isValid && foundPlaceView.isValid {
122+
// TODO: ViewModel 호출
123+
}
124+
}
125+
}
126+
127+
extension EditLostItemViewController {
128+
129+
private func addObserver() {
130+
NotificationCenter.default.addObserver(
131+
self,
132+
selector: #selector(keyBoardWillShow(_:)),
133+
name: UIResponder.keyboardWillShowNotification,
134+
object: nil
135+
)
136+
137+
NotificationCenter.default.addObserver(
138+
self,
139+
selector: #selector(keyBoardWillHide(_:)),
140+
name: UIResponder.keyboardWillHideNotification,
141+
object: nil)
142+
}
143+
144+
@objc private func keyBoardWillShow(_ notification: NSNotification) {
145+
guard let userInfo = notification.userInfo,
146+
let keyBoardSize = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else {
147+
return
148+
}
149+
150+
let contentInset = UIEdgeInsets(
151+
top: 0,
152+
left: 0,
153+
bottom: keyBoardSize.size.height - (view.frame.height - scrollView.frame.maxY),
154+
right: 0
155+
)
156+
scrollView.contentInset = contentInset
157+
scrollView.scrollIndicatorInsets = contentInset
158+
159+
160+
guard let targetView = [foundPlaceView.locationTextField,
161+
contentView.contentTextView].first(where: { $0.isFirstResponder }) else {
162+
return
163+
}
164+
165+
var rect = targetView.convert(targetView.bounds, to: scrollView)
166+
rect.size.height += 16
167+
scrollView.scrollRectToVisible(rect, animated: true)
168+
}
169+
170+
@objc private func keyBoardWillHide(_ notification: NSNotification) {
171+
let contentInset = UIEdgeInsets.zero
172+
scrollView.contentInset = contentInset
173+
scrollView.scrollIndicatorInsets = contentInset
174+
}
175+
}
176+
177+
extension EditLostItemViewController: PHPickerViewControllerDelegate {
178+
179+
private func addImageButtonTapped() {
180+
var configuration = PHPickerConfiguration()
181+
configuration.filter = .images
182+
configuration.selectionLimit = 1
183+
184+
let picker = PHPickerViewController(configuration: configuration)
185+
picker.delegate = self
186+
present(picker, animated: true, completion: nil)
187+
}
188+
189+
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
190+
picker.dismiss(animated: true, completion: nil)
191+
192+
guard let provider = results.first?.itemProvider else { return }
193+
194+
if provider.canLoadObject(ofClass: UIImage.self) {
195+
provider.loadObject(ofClass: UIImage.self) { [weak self] image, error in
196+
DispatchQueue.main.async {
197+
if let selectedImage = image as? UIImage {
198+
self?.handleSelectedImage(image: selectedImage)
199+
}
200+
}
201+
}
202+
}
203+
}
204+
205+
private func handleSelectedImage(image: UIImage) {
206+
guard let imageData = image.jpegData(compressionQuality: 0.5) else {
207+
return
208+
}
209+
inputSubject.send(.uploadFile([imageData]))
210+
}
211+
}
212+
213+
extension EditLostItemViewController {
214+
215+
private func setUpLayOuts() {
216+
[headerView, topSeparateView, imagesView, categoryView, foundPlaceView, contentView, foundDateView].forEach {
217+
scrollContentView.addSubview($0)
218+
}
219+
[scrollContentView].forEach {
220+
scrollView.addSubview($0)
221+
}
222+
[bottomSeparateView, editButton, scrollView].forEach {
223+
view.addSubview($0)
224+
}
225+
}
226+
227+
private func setUpConstraints() {
228+
scrollView.snp.makeConstraints {
229+
$0.top.equalTo(view.safeAreaLayoutGuide)
230+
$0.leading.trailing.equalToSuperview()
231+
$0.bottom.equalTo(bottomSeparateView.snp.top)
232+
}
233+
scrollContentView.snp.makeConstraints {
234+
$0.edges.equalToSuperview()
235+
$0.width.equalToSuperview()
236+
}
237+
238+
headerView.snp.makeConstraints {
239+
$0.top.leading.trailing.equalToSuperview()
240+
}
241+
topSeparateView.snp.makeConstraints {
242+
$0.top.equalTo(headerView.snp.bottom)
243+
$0.leading.trailing.equalToSuperview()
244+
$0.height.equalTo(6)
245+
}
246+
imagesView.snp.makeConstraints {
247+
$0.top.equalTo(topSeparateView.snp.bottom).offset(16)
248+
$0.leading.trailing.equalToSuperview().inset(24)
249+
}
250+
categoryView.snp.makeConstraints {
251+
$0.top.equalTo(imagesView.snp.bottom).offset(24)
252+
$0.leading.trailing.equalToSuperview().inset(24)
253+
}
254+
foundDateView.snp.makeConstraints {
255+
$0.top.equalTo(categoryView.snp.bottom).offset(16)
256+
$0.leading.trailing.equalToSuperview().inset(24)
257+
}
258+
foundPlaceView.snp.makeConstraints {
259+
$0.top.equalTo(foundDateView.snp.bottom).offset(16)
260+
$0.leading.trailing.equalToSuperview().inset(24)
261+
}
262+
contentView.snp.makeConstraints {
263+
$0.top.equalTo(foundPlaceView.snp.bottom).offset(16)
264+
$0.leading.trailing.equalToSuperview().inset(24)
265+
$0.bottom.equalToSuperview().offset(-16)
266+
}
267+
268+
bottomSeparateView.snp.makeConstraints {
269+
$0.height.equalTo(1)
270+
$0.leading.trailing.equalToSuperview()
271+
$0.bottom.equalTo(editButton.snp.top).offset(-24)
272+
}
273+
editButton.snp.makeConstraints {
274+
$0.width.equalTo(160)
275+
$0.height.equalTo(38)
276+
$0.centerX.equalToSuperview()
277+
$0.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
278+
}
279+
}
280+
281+
private func configureView() {
282+
setUpLayOuts()
283+
setUpConstraints()
284+
self.view.backgroundColor = .appColor(.neutral0)
285+
}
286+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
//
2+
// PostLostItemViewModel.swift
3+
// koin
4+
//
5+
// Created by 홍기정 on 1/22/26.
6+
//
7+
8+
import Combine
9+
import Foundation
10+
11+
final class EditLostItemViewModel: ViewModelProtocol {
12+
13+
// MARK: - Input
14+
enum Input {
15+
case uploadFile([Data])
16+
}
17+
18+
// MARK: - Output
19+
enum Output {
20+
case showToast(String)
21+
case addImageUrl(String)
22+
}
23+
24+
// MARK: - Properties
25+
private let outputSubject = PassthroughSubject<Output, Never>()
26+
private var subscriptions: Set<AnyCancellable> = []
27+
28+
private(set) var lostItemData: LostItemData
29+
30+
private let uploadFileUseCase: UploadFileUseCase = DefaultUploadFileUseCase(shopRepository: DefaultShopRepository(service: DefaultShopService()))
31+
32+
33+
// MARK: - Initialization
34+
init(lostItemData: LostItemData) {
35+
self.lostItemData = lostItemData
36+
}
37+
38+
func transform(with input: AnyPublisher<Input, Never>) -> AnyPublisher<Output, Never> {
39+
input.sink { [weak self] input in
40+
guard let self else { return }
41+
switch input {
42+
case let .uploadFile(files):
43+
self.uploadFiles(files: files)
44+
}
45+
}.store(in: &subscriptions)
46+
return outputSubject.eraseToAnyPublisher()
47+
}
48+
}
49+
50+
extension EditLostItemViewModel {
51+
52+
private func uploadFiles(files: [Data]) {
53+
uploadFileUseCase.execute(files: files).sink { [weak self] completion in
54+
if case let .failure(error) = completion {
55+
self?.outputSubject.send(.showToast(error.message))
56+
}
57+
} receiveValue: { [weak self] response in
58+
response.fileUrls.forEach {
59+
self?.outputSubject.send(.addImageUrl($0))
60+
}
61+
}.store(in: &subscriptions)
62+
63+
}
64+
}

0 commit comments

Comments
 (0)