Skip to content

Commit 6d8c37c

Browse files
committed
Example: editor screen
1 parent f3df1a6 commit 6d8c37c

File tree

3 files changed

+221
-5
lines changed

3 files changed

+221
-5
lines changed

Example/Presentation/Base.lproj/Main.storyboard

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,42 @@
159159
<view key="view" contentMode="scaleToFill" id="Dqy-OV-IFM">
160160
<rect key="frame" x="0.0" y="0.0" width="414" height="842"/>
161161
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
162+
<subviews>
163+
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="UY9-Bt-nvR">
164+
<rect key="frame" x="0.0" y="0.0" width="414" height="842"/>
165+
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
166+
<string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string>
167+
<color key="textColor" systemColor="labelColor"/>
168+
<fontDescription key="fontDescription" type="system" pointSize="14"/>
169+
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
170+
</textView>
171+
</subviews>
162172
<viewLayoutGuide key="safeArea" id="PAV-oK-u9U"/>
163173
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
174+
<constraints>
175+
<constraint firstAttribute="bottom" secondItem="UY9-Bt-nvR" secondAttribute="bottom" id="7pR-o7-w5Q"/>
176+
<constraint firstItem="UY9-Bt-nvR" firstAttribute="leading" secondItem="Dqy-OV-IFM" secondAttribute="leading" id="Unm-1k-oef"/>
177+
<constraint firstAttribute="trailing" secondItem="UY9-Bt-nvR" secondAttribute="trailing" id="Zy6-yf-aUN"/>
178+
<constraint firstItem="UY9-Bt-nvR" firstAttribute="top" secondItem="Dqy-OV-IFM" secondAttribute="top" id="jb6-52-Fqr"/>
179+
</constraints>
164180
</view>
165-
<navigationItem key="navigationItem" title="Note" id="Zja-3p-t35"/>
181+
<navigationItem key="navigationItem" title="Note" id="Zja-3p-t35">
182+
<barButtonItem key="leftBarButtonItem" title="Close" id="W3n-ck-6Dh">
183+
<connections>
184+
<action selector="close" destination="wbC-kD-tpp" id="Phe-Bd-ugT"/>
185+
</connections>
186+
</barButtonItem>
187+
<barButtonItem key="rightBarButtonItem" title="Save" id="wRH-lz-G3w">
188+
<connections>
189+
<action selector="save" destination="wbC-kD-tpp" id="Ff8-PS-Rco"/>
190+
</connections>
191+
</barButtonItem>
192+
</navigationItem>
193+
<connections>
194+
<outlet property="closeBarItem" destination="W3n-ck-6Dh" id="PCj-e7-VyE"/>
195+
<outlet property="saveBarItem" destination="wRH-lz-G3w" id="4w9-2o-gfe"/>
196+
<outlet property="textView" destination="UY9-Bt-nvR" id="aAU-1N-KTn"/>
197+
</connections>
166198
</viewController>
167199
<placeholder placeholderIdentifier="IBFirstResponder" id="0HS-2z-SOG" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
168200
</objects>
@@ -190,6 +222,9 @@
190222
</inferredMetricsTieBreakers>
191223
<resources>
192224
<image name="person.circle" catalog="system" width="128" height="121"/>
225+
<systemColor name="labelColor">
226+
<color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
227+
</systemColor>
193228
<systemColor name="systemBackgroundColor">
194229
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
195230
</systemColor>

Example/Presentation/NoteEdit/NoteEditPresenter.swift

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,103 @@
99
import Foundation
1010
import Combine
1111

12+
enum NoteEditViewModel {
13+
enum EditState: Hashable {
14+
case new
15+
case newInvalid
16+
case changed
17+
case invalid
18+
case saved
19+
}
20+
}
21+
1222
protocol NoteEditPresenter: class {
23+
func configure(
24+
showAlertHandler: @escaping (String, String) -> Void
25+
)
26+
1327
var titlePublisher: AnyPublisher<String, Never> { get }
28+
var editStatePublisher: AnyPublisher<NoteEditViewModel.EditState, Never> { get }
29+
30+
var contentText: String { get set }
31+
func save(completion: ((Bool) -> Void)?)
1432
}
1533

1634
final class NoteEditPresenterImpl: NoteEditPresenter {
1735
private let editService: NoteRecordEditService
1836

37+
private var showAlert: (String, String) -> Void = { _, _ in }
38+
39+
@Observable
40+
var contentText: String
41+
42+
var editStatePublisher: AnyPublisher<NoteEditViewModel.EditState, Never> {
43+
editService.recordPublisher.combineLatest($contentText).map { savedRecord, currentText in
44+
if let savedText = savedRecord?.content.content {
45+
if savedText == currentText {
46+
return .saved
47+
} else {
48+
return currentText.isEmpty ? .invalid : .changed
49+
}
50+
} else {
51+
return currentText.isEmpty ? .newInvalid : .new
52+
}
53+
}.removeDuplicates().eraseToAnyPublisher()
54+
}
55+
1956
var titlePublisher: AnyPublisher<String, Never> {
20-
editService.recordPublisher.map {
21-
$0 != nil ? "Edit note" : "Add note"
22-
}.eraseToAnyPublisher()
57+
editStatePublisher.map {
58+
switch $0 {
59+
case .new, .newInvalid: return "New note"
60+
case .changed, .saved, .invalid: return "Edit note"
61+
}
62+
}.removeDuplicates().eraseToAnyPublisher()
2363
}
2464

2565
init(editService: NoteRecordEditService) {
2666
self.editService = editService
67+
self.contentText = editService.record?.content.content ?? ""
68+
}
69+
70+
func configure(
71+
showAlertHandler: @escaping (String, String) -> Void
72+
) {
73+
self.showAlert = showAlertHandler
74+
}
75+
76+
func save(completion: ((Bool) -> Void)?) {
77+
let title = makeTitle(for: contentText)
78+
let content = NoteRecord.Content(title: title, content: contentText)
79+
80+
editService.apply(content: content) { [weak self] result in
81+
switch result {
82+
case .success: completion?(true)
83+
case .failure(let error):
84+
self?.showErrorAlert(title: "Failed to save note", error: error)
85+
completion?(false)
86+
}
87+
}
88+
}
89+
90+
// MARK: - Private
91+
private func showErrorAlert(title: String, error: Error) {
92+
let message = error.localizedDescription
93+
showAlert(title, message)
2794
}
2895

96+
private func makeTitle(for content: String) -> String {
97+
let firstLine = content.split(separator: "\n").first ?? ""
98+
99+
let maxLength = 32
100+
if firstLine.count > maxLength {
101+
let str = firstLine.prefix(maxLength)
102+
if let index = str.lastIndex(of: " ") {
103+
return String(str[str.startIndex..<index])
104+
} else {
105+
return String(str)
106+
}
107+
} else {
108+
return String(firstLine)
109+
}
110+
}
29111
}

Example/Presentation/NoteEdit/NoteEditViewController.swift

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,112 @@ class NoteEditViewController: UIViewController {
1515
private var presenter
1616

1717
private var cancellableSet: Set<AnyCancellable> = []
18+
19+
@IBOutlet private var closeBarItem: UIBarButtonItem!
20+
@IBOutlet private var saveBarItem: UIBarButtonItem!
21+
@IBOutlet private weak var textView: UITextView!
1822

1923
override func viewDidLoad() {
2024
super.viewDidLoad()
21-
25+
navigationController?.presentationController?.delegate = self
26+
27+
presenter.configure(
28+
showAlertHandler: { [weak self] title, message in
29+
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
30+
alert.addAction(.init(title: "OK", style: .default, handler: nil))
31+
self?.present(alert, animated: true, completion: nil)
32+
}
33+
)
34+
35+
textView.textContainer.lineFragmentPadding = 0
36+
updateTextViewInsets(keyboardHeight: 0)
37+
38+
textView.text = presenter.contentText
39+
textView.delegate = self
40+
2241
presenter.titlePublisher.sink { [weak self] in
2342
self?.navigationItem.title = $0
2443
}.store(in: &cancellableSet)
44+
45+
presenter.editStatePublisher.sink { [weak self] in
46+
self?.updateForEditState($0)
47+
}.store(in: &cancellableSet)
48+
49+
NotificationCenter.default
50+
.publisher(for: UIResponder.keyboardWillChangeFrameNotification)
51+
.sink { [weak self] notification in
52+
guard let self = self,
53+
let keyboardValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return }
54+
let keyboardScreenEndFrame = keyboardValue.cgRectValue
55+
let keyboardViewEndFrame = self.view.convert(keyboardScreenEndFrame, from: self.view.window)
56+
let keyboardHeight = keyboardViewEndFrame.height - self.view.safeAreaInsets.bottom
57+
self.updateTextViewInsets(keyboardHeight: keyboardHeight)
58+
self.scrollToCursorTextView()
59+
}.store(in: &cancellableSet)
60+
61+
NotificationCenter.default
62+
.publisher(for: UIResponder.keyboardWillHideNotification)
63+
.sink { [weak self] _ in
64+
self?.updateTextViewInsets(keyboardHeight: 0)
65+
}.store(in: &cancellableSet)
66+
}
67+
68+
// MARK: Actions
69+
@IBAction private func close() {
70+
dismiss(animated: true, completion: nil)
71+
}
72+
73+
@IBAction private func save() {
74+
presenter.save(completion: nil)
75+
}
76+
77+
private func confirmClose() {
78+
let alert = UIAlertController(title: "Save changes?", message: nil, preferredStyle: .actionSheet)
79+
alert.addAction(.init(title: "Cancel", style: .cancel, handler: nil))
80+
alert.addAction(.init(title: "Save", style: .default, handler: { [weak self] _ in
81+
self?.presenter.save(completion: { success in
82+
if success {
83+
self?.close()
84+
}
85+
})
86+
}))
87+
alert.addAction(.init(title: "Not save", style: .destructive, handler: { [weak self] _ in
88+
self?.close()
89+
}))
90+
91+
present(alert, animated: true, completion: nil)
92+
}
93+
94+
// MARK: - Private
95+
private func updateForEditState(_ state: NoteEditViewModel.EditState) {
96+
closeBarItem?.title = state == .saved ? "Done" : "Cancel"
97+
saveBarItem?.title = (state == .new || state == .newInvalid) ? "Add" : "Save"
98+
99+
let canSaved = state == .new || state == .changed
100+
saveBarItem?.isEnabled = canSaved
101+
isModalInPresentation = canSaved
102+
}
103+
104+
private func updateTextViewInsets(keyboardHeight: CGFloat) {
105+
let padding: CGFloat = 16
106+
textView.contentInset = .init(top: padding, left: padding, bottom: padding + keyboardHeight, right: padding)
107+
textView.scrollIndicatorInsets = .init(top: 0, left: 0, bottom: keyboardHeight, right: 0)
25108
}
26109

110+
private func scrollToCursorTextView() {
111+
let selectedRange = textView.selectedRange
112+
textView.scrollRangeToVisible(selectedRange)
113+
}
114+
}
115+
116+
extension NoteEditViewController: UITextViewDelegate {
117+
func textViewDidChange(_ textView: UITextView) {
118+
presenter.contentText = textView.text ?? ""
119+
}
120+
}
121+
122+
extension NoteEditViewController: UIAdaptivePresentationControllerDelegate {
123+
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
124+
confirmClose()
125+
}
27126
}

0 commit comments

Comments
 (0)