diff --git a/CHANGELOG.md b/CHANGELOG.md index 655230a..6be7b93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [Mac] Fixed a bug where alerts weren't presented for login errors. - [Mac] Fixed some build warnings in the project. - [Mac] Bumped WriteFreely package to v0.3.6 to handle decoding of fractional seconds in dates. +- [iOS] Fixed an issue that made it tricky to scroll in the post editor. +- [iOS] Fixed a bug that didn't navigate to the post editor after tapping the new-post button. ## [1.0.12-ios] - 2022-10-06 diff --git a/Shared/PostList/PostListView.swift b/Shared/PostList/PostListView.swift index dbeb16b..93d0567 100644 --- a/Shared/PostList/PostListView.swift +++ b/Shared/PostList/PostListView.swift @@ -47,7 +47,9 @@ struct PostListView: View { let managedPost = model.editor.generateNewLocalPost(withFont: model.preferences.font) withAnimation { self.model.showAllPosts = false - self.model.selectedPost = managedPost + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.model.selectedPost = managedPost + } } }, label: { ZStack { diff --git a/Shared/PostList/PostStatusBadgeView.swift b/Shared/PostList/PostStatusBadgeView.swift index ffecd2c..7a8e45e 100644 --- a/Shared/PostList/PostStatusBadgeView.swift +++ b/Shared/PostList/PostStatusBadgeView.swift @@ -14,7 +14,7 @@ struct PostStatusBadgeView: View { .padding(EdgeInsets(top: 2.5, leading: 7.5, bottom: 2.5, trailing: 7.5)) .background(badgeColor) .clipShape(RoundedRectangle(cornerRadius: 5.0, style: .circular)) - .frame(width: .infinity) + .frame(maxWidth: .infinity) } func setupBadgeProperties(for status: PostStatus) -> (String, Color) { diff --git a/WriteFreely-MultiPlatform.xcodeproj/project.pbxproj b/WriteFreely-MultiPlatform.xcodeproj/project.pbxproj index 24a449b..ab8fb6d 100644 --- a/WriteFreely-MultiPlatform.xcodeproj/project.pbxproj +++ b/WriteFreely-MultiPlatform.xcodeproj/project.pbxproj @@ -90,8 +90,6 @@ 17A5388F24DDEC7400DEFF9A /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A5388D24DDEC7400DEFF9A /* AccountView.swift */; }; 17A5389324DDED0000DEFF9A /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A5389124DDED0000DEFF9A /* PreferencesView.swift */; }; 17A67CAF251A5DD7002F163D /* PostEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A67CAE251A5DD7002F163D /* PostEditorView.swift */; }; - 17AD0A5E25489E810057D763 /* PostTitleTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17AD0A5D25489E810057D763 /* PostTitleTextView.swift */; }; - 17AD0A6425489E900057D763 /* PostBodyTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17AD0A6325489E900057D763 /* PostBodyTextView.swift */; }; 17B37C4B25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B37C4A25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift */; }; 17B37C4C25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B37C4A25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift */; }; 17B37C5625C8679800FE75E9 /* WriteFreelyModel+API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B37C5525C8679800FE75E9 /* WriteFreelyModel+API.swift */; }; @@ -138,6 +136,7 @@ 17DFDE8B251D309400A25F31 /* OpenSans-License.txt in Resources */ = {isa = PBXBuildFile; fileRef = 17DFDE86251D309400A25F31 /* OpenSans-License.txt */; }; 17DFDE8C251D309400A25F31 /* OpenSans-License.txt in Resources */ = {isa = PBXBuildFile; fileRef = 17DFDE86251D309400A25F31 /* OpenSans-License.txt */; }; 17E5DF8A2543610700DCDC9B /* PostTextEditingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E5DF892543610700DCDC9B /* PostTextEditingView.swift */; }; + 375A67E828FC555C007A1AC0 /* MultilineTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375A67E728FC555C007A1AC0 /* MultilineTextView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -227,8 +226,6 @@ 17A5388D24DDEC7400DEFF9A /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = ""; }; 17A5389124DDED0000DEFF9A /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; 17A67CAE251A5DD7002F163D /* PostEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostEditorView.swift; sourceTree = ""; }; - 17AD0A5D25489E810057D763 /* PostTitleTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostTitleTextView.swift; sourceTree = ""; }; - 17AD0A6325489E900057D763 /* PostBodyTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostBodyTextView.swift; sourceTree = ""; }; 17B37C4A25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WriteFreelyModel+Keychain.swift"; sourceTree = ""; }; 17B37C5525C8679800FE75E9 /* WriteFreelyModel+API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WriteFreelyModel+API.swift"; sourceTree = ""; }; 17B37C5C25C8698900FE75E9 /* WriteFreelyModel+APIHandlers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WriteFreelyModel+APIHandlers.swift"; sourceTree = ""; }; @@ -270,6 +267,7 @@ 17DFDE85251D309400A25F31 /* Lora-Cyrillic-OFL.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "Lora-Cyrillic-OFL.txt"; sourceTree = ""; }; 17DFDE86251D309400A25F31 /* OpenSans-License.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "OpenSans-License.txt"; sourceTree = ""; }; 17E5DF892543610700DCDC9B /* PostTextEditingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostTextEditingView.swift; sourceTree = ""; }; + 375A67E728FC555C007A1AC0 /* MultilineTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultilineTextView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -436,8 +434,7 @@ 1756AE7624CB2EDD00FD7257 /* PostEditorView.swift */, 173E19D0254318F600440F0F /* RemoteChangePromptView.swift */, 173E19E2254329CC00440F0F /* PostTextEditingView.swift */, - 17AD0A5D25489E810057D763 /* PostTitleTextView.swift */, - 17AD0A6325489E900057D763 /* PostBodyTextView.swift */, + 375A67E728FC555C007A1AC0 /* MultilineTextView.swift */, ); path = PostEditor; sourceTree = ""; @@ -926,13 +923,12 @@ 17120DAC24E1B99F002B9F6C /* AccountLoginView.swift in Sources */, 17B37C4B25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift in Sources */, 17480CA5251272EE00EB7765 /* Bundle+AppVersion.swift in Sources */, - 17AD0A6425489E900057D763 /* PostBodyTextView.swift in Sources */, 17B37C5D25C8698900FE75E9 /* WriteFreelyModel+APIHandlers.swift in Sources */, - 17AD0A5E25489E810057D763 /* PostTitleTextView.swift in Sources */, 17120DA924E1B2F5002B9F6C /* AccountLogoutView.swift in Sources */, 171BFDFA24D4AF8300888236 /* CollectionListView.swift in Sources */, 1756DBB324FECDBB00207AB8 /* PostEditorStatusToolbarView.swift in Sources */, 17120DB224E1E19C002B9F6C /* SettingsHeaderView.swift in Sources */, + 375A67E828FC555C007A1AC0 /* MultilineTextView.swift in Sources */, 171DC677272C7D0B002B9B8A /* UserDefaults+Extensions.swift in Sources */, 1756DBB724FED3A400207AB8 /* LocalStorageModel.xcdatamodeld in Sources */, 17B996DA2502D23E0017B536 /* WFAPost+CoreDataProperties.swift in Sources */, @@ -1054,7 +1050,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_ENTITLEMENTS = "ActionExtension-iOS/ActionExtension-iOS.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 680; + CURRENT_PROJECT_VERSION = 687; DEVELOPMENT_TEAM = TPPAB4YBA6; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "ActionExtension-iOS/Info.plist"; @@ -1066,7 +1062,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.12; + MARKETING_VERSION = 1.0.13; PRODUCT_BUNDLE_IDENTIFIER = "com.abunchtell.WriteFreely-MultiPlatform.ActionExtension-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -1085,7 +1081,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_ENTITLEMENTS = "ActionExtension-iOS/ActionExtension-iOS.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 680; + CURRENT_PROJECT_VERSION = 687; DEVELOPMENT_TEAM = TPPAB4YBA6; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "ActionExtension-iOS/Info.plist"; @@ -1097,7 +1093,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.12; + MARKETING_VERSION = 1.0.13; PRODUCT_BUNDLE_IDENTIFIER = "com.abunchtell.WriteFreely-MultiPlatform.ActionExtension-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -1228,7 +1224,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "WriteFreely-MultiPlatform (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 680; + CURRENT_PROJECT_VERSION = 687; DEVELOPMENT_TEAM = TPPAB4YBA6; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = iOS/Info.plist; @@ -1237,7 +1233,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.12; + MARKETING_VERSION = 1.0.13; PRODUCT_BUNDLE_IDENTIFIER = "com.abunchtell.WriteFreely-MultiPlatform"; PRODUCT_NAME = "WriteFreely-MultiPlatform"; SDKROOT = iphoneos; @@ -1254,7 +1250,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "WriteFreely-MultiPlatform (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 680; + CURRENT_PROJECT_VERSION = 687; DEVELOPMENT_TEAM = TPPAB4YBA6; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = iOS/Info.plist; @@ -1263,7 +1259,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.12; + MARKETING_VERSION = 1.0.13; PRODUCT_BUNDLE_IDENTIFIER = "com.abunchtell.WriteFreely-MultiPlatform"; PRODUCT_NAME = "WriteFreely-MultiPlatform"; SDKROOT = iphoneos; @@ -1282,7 +1278,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 676; + CURRENT_PROJECT_VERSION = 687; DEVELOPMENT_TEAM = TPPAB4YBA6; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -1309,7 +1305,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 676; + CURRENT_PROJECT_VERSION = 687; DEVELOPMENT_TEAM = TPPAB4YBA6; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -1489,7 +1485,7 @@ repositoryURL = "https://github.com/sparkle-project/Sparkle"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 2.0.0; + minimumVersion = 2.3.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/iOS/PostEditor/MultilineTextView.swift b/iOS/PostEditor/MultilineTextView.swift new file mode 100644 index 0000000..b3afd97 --- /dev/null +++ b/iOS/PostEditor/MultilineTextView.swift @@ -0,0 +1,159 @@ +// Credit: https://stackoverflow.com/a/58639072 + +import SwiftUI +import UIKit + +private struct UITextViewWrapper: UIViewRepresentable { + typealias UIViewType = UITextView + + @Binding var text: String + @Binding var calculatedHeight: CGFloat + @Binding var isEditing: Bool + var textStyle: UIFont + var onDone: (() -> Void)? + + func makeUIView(context: UIViewRepresentableContext) -> UITextView { + let textField = UITextView() + textField.delegate = context.coordinator + + textField.isEditable = true + textField.font = UIFont.preferredFont(forTextStyle: .body) + textField.isSelectable = true + textField.isUserInteractionEnabled = true + textField.isScrollEnabled = false + textField.backgroundColor = UIColor.clear + textField.smartDashesType = .no + + let font = textStyle + let fontMetrics = UIFontMetrics(forTextStyle: .largeTitle) + textField.font = fontMetrics.scaledFont(for: font) + + if nil != onDone { + textField.returnKeyType = .next + } + + textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return textField + } + + func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext) { + if uiView.text != self.text { + uiView.text = self.text + } + + if uiView.window != nil, isEditing { + uiView.becomeFirstResponder() + } + + UITextViewWrapper.recalculateHeight(view: uiView, result: $calculatedHeight) + } + + fileprivate static func recalculateHeight(view: UIView, result: Binding) { + let newSize = view.sizeThatFits(CGSize(width: view.frame.size.width, height: CGFloat.greatestFiniteMagnitude)) + if result.wrappedValue != newSize.height { + DispatchQueue.main.async { + result.wrappedValue = newSize.height // !! must be called asynchronously + } + } + } + + func makeCoordinator() -> Coordinator { + return Coordinator(text: $text, height: $calculatedHeight, isFirstResponder: $isEditing, onDone: onDone) + } + + final class Coordinator: NSObject, UITextViewDelegate { + @Binding var isFirstResponder: Bool + var text: Binding + var calculatedHeight: Binding + var onDone: (() -> Void)? + + init( + text: Binding, + height: Binding, + isFirstResponder: Binding, + onDone: (() -> Void)? = nil + ) { + self.text = text + self.calculatedHeight = height + self._isFirstResponder = isFirstResponder + self.onDone = onDone + } + + func textViewDidChange(_ uiView: UITextView) { + text.wrappedValue = uiView.text + UITextViewWrapper.recalculateHeight(view: uiView, result: calculatedHeight) + } + + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + if let onDone = self.onDone, text == "\n" { + textView.resignFirstResponder() + onDone() + return false + } + return true + } + + func textViewDidEndEditing(_ textView: UITextView) { + self.isFirstResponder = false + } + } + +} + +struct MultilineTextField: View { + + private var placeholder: String + private var textStyle: UIFont + private var onCommit: (() -> Void)? + + @Binding var isFirstResponder: Bool + @Binding private var text: String + private var internalText: Binding { + Binding(get: { self.text }) { // swiftlint:disable:this multiple_closures_with_trailing_closure + self.text = $0 + self.showingPlaceholder = $0.isEmpty + } + } + + @State private var dynamicHeight: CGFloat = 100 + @State private var showingPlaceholder = false + + init ( + _ placeholder: String = "", + text: Binding, + font: UIFont, + isFirstResponder: Binding, + onCommit: (() -> Void)? = nil + ) { + self.placeholder = placeholder + self.onCommit = onCommit + self.textStyle = font + self._isFirstResponder = isFirstResponder + self._text = text + self._showingPlaceholder = State(initialValue: self.text.isEmpty) + } + + var body: some View { + UITextViewWrapper( + text: self.internalText, + calculatedHeight: $dynamicHeight, + isEditing: $isFirstResponder, + textStyle: textStyle, + onDone: onCommit + ) + .frame(minHeight: dynamicHeight, maxHeight: dynamicHeight) + .background(placeholderView, alignment: .topLeading) + } + + var placeholderView: some View { + Group { + if showingPlaceholder { + let font = Font(textStyle) + Text(placeholder).foregroundColor(.gray) + .padding(.leading, 4) + .padding(.top, 8) + .font(font) + } + } + } +} diff --git a/iOS/PostEditor/PostBodyTextView.swift b/iOS/PostEditor/PostBodyTextView.swift deleted file mode 100644 index b287618..0000000 --- a/iOS/PostEditor/PostBodyTextView.swift +++ /dev/null @@ -1,134 +0,0 @@ -// Based on https://stackoverflow.com/a/56508132 and https://stackoverflow.com/a/48360549 - -import SwiftUI - -class PostBodyCoordinator: NSObject, UITextViewDelegate, NSLayoutManagerDelegate { - @Binding var text: String - @Binding var isFirstResponder: Bool - var lineSpacingMultiplier: CGFloat - var didBecomeFirstResponder: Bool = false - var postBodyTextView: PostBodyTextView - - weak var textView: UITextView? - - init( - _ textView: PostBodyTextView, - text: Binding, - isFirstResponder: Binding, - lineSpacingMultiplier: CGFloat - ) { - self.postBodyTextView = textView - _text = text - _isFirstResponder = isFirstResponder - self.lineSpacingMultiplier = lineSpacingMultiplier - - super.init() - - updateSize() - } - - func updateSize() { - DispatchQueue.main.async { - guard let view = self.textView else { return } - let size = view.sizeThatFits(view.bounds.size) - if self.postBodyTextView.height != size.height { - self.postBodyTextView.height = size.height - } - } - } - - func textViewDidChange(_ textView: UITextView) { - DispatchQueue.main.async { - self.postBodyTextView.text = textView.text ?? "" - } - } - - func textViewDidEndEditing(_ textView: UITextView) { - self.isFirstResponder = false - self.didBecomeFirstResponder = false - } - - func layoutManager( - _ layoutManager: NSLayoutManager, - didCompleteLayoutFor textContainer: NSTextContainer?, - atEnd layoutFinishedFlag: Bool - ) { - updateSize() - } - - func layoutManager( - _ layoutManager: NSLayoutManager, - lineSpacingAfterGlyphAt glyphIndex: Int, - withProposedLineFragmentRect rect: CGRect - ) -> CGFloat { - // HACK: - This seems to be the only way to get line spacing to update dynamically on iPad - // when switching between full-screen, split-screen, and slide-over views. - if let window = UIApplication.shared.windows.filter({ $0.isKeyWindow }).first { - // Get the width of the window to determine the size class - if window.frame.width < 600 { - // Use 0.25 multiplier for compact size class - return 17 * 0.25 - } else { - // Use 0.5 multiplier otherwise - return 17 * 0.5 - } - } else { - return 17 * lineSpacingMultiplier - } - } -} - -struct PostBodyTextView: UIViewRepresentable { - @Binding var text: String - @Binding var textStyle: UIFont - @Binding var height: CGFloat - @Binding var isFirstResponder: Bool - @State var lineSpacing: CGFloat - - func makeUIView(context: UIViewRepresentableContext) -> UITextView { - let textView = UITextView() - - textView.isEditable = true - textView.isUserInteractionEnabled = true - textView.isScrollEnabled = true - textView.alwaysBounceVertical = false - textView.smartDashesType = .no - - context.coordinator.textView = textView - textView.delegate = context.coordinator - textView.layoutManager.delegate = context.coordinator - - let font = textStyle - let fontMetrics = UIFontMetrics(forTextStyle: .largeTitle) - textView.font = fontMetrics.scaledFont(for: font) - - textView.backgroundColor = UIColor.clear - - return textView - } - - func makeCoordinator() -> PostBodyCoordinator { - return Coordinator( - self, - text: $text, - isFirstResponder: $isFirstResponder, - lineSpacingMultiplier: lineSpacing - ) - } - - func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext) { - if uiView.text != text { - uiView.text = text - } - - let font = textStyle - let fontMetrics = UIFontMetrics(forTextStyle: .largeTitle) - uiView.font = fontMetrics.scaledFont(for: font) - - // We don't want the text field to become first responder every time SwiftUI refreshes the view. - if isFirstResponder && !context.coordinator.didBecomeFirstResponder { - uiView.becomeFirstResponder() - context.coordinator.didBecomeFirstResponder = true - } - } -} diff --git a/iOS/PostEditor/PostTextEditingView.swift b/iOS/PostEditor/PostTextEditingView.swift index 57241c5..7184105 100644 --- a/iOS/PostEditor/PostTextEditingView.swift +++ b/iOS/PostEditor/PostTextEditingView.swift @@ -7,8 +7,6 @@ struct PostTextEditingView: View { @Binding var updatingBodyFromServer: Bool @State private var appearance: PostAppearance = .serif @State private var titleTextStyle: UIFont = UIFont(name: "Lora-Regular", size: 26)! - @State private var titleTextHeight: CGFloat = 50 - @State private var bodyTextHeight: CGFloat = 50 @State private var titleIsFirstResponder: Bool = true @State private var bodyTextStyle: UIFont = UIFont(name: "Lora-Regular", size: 17)! @State private var bodyIsFirstResponder: Bool = false @@ -26,77 +24,39 @@ struct PostTextEditingView: View { UITextView.appearance().backgroundColor = .clear } - var titleFieldHeight: CGFloat { - let minHeight: CGFloat = textEditorHeight - if titleTextHeight < minHeight { - return minHeight - } - return titleTextHeight - } - var bodyFieldHeight: CGFloat { - let minHeight: CGFloat = textEditorHeight - if bodyTextHeight < minHeight { - return minHeight - } - return bodyTextHeight - } - var body: some View { ScrollView(.vertical) { - ZStack(alignment: .topLeading) { - if post.title.count == 0 { - Text("Title (optional)") - .font(Font(titleTextStyle)) - .foregroundColor(Color(UIColor.placeholderText)) - .padding(.horizontal, 4) - .padding(.vertical, 8) - .accessibilityHidden(true) + MultilineTextField( + "Title (optional)", + text: $post.title, + font: titleTextStyle, + isFirstResponder: $titleIsFirstResponder, + onCommit: didFinishEditingTitle + ) + .accessibilityLabel(Text("Title (optional)")) + .accessibilityHint(Text("Add or edit the title for your post; use the Return key to skip to the body")) + .onChange(of: post.title) { _ in + if post.status == PostStatus.published.rawValue && !updatingTitleFromServer { + post.status = PostStatus.edited.rawValue } - PostTitleTextView( - text: $post.title, - textStyle: $titleTextStyle, - height: $titleTextHeight, - isFirstResponder: $titleIsFirstResponder, - lineSpacing: horizontalSizeClass == .compact ? lineSpacingMultiplier / 2 : lineSpacingMultiplier - ) - .accessibilityLabel(Text("Title (optional)")) - .accessibilityHint(Text("Add or edit the title for your post; use the Return key to skip to the body")) - .frame(height: titleFieldHeight) - .onChange(of: post.title) { _ in - if post.status == PostStatus.published.rawValue && !updatingTitleFromServer { - post.status = PostStatus.edited.rawValue - } - if updatingTitleFromServer { - updatingTitleFromServer = false - } + if updatingTitleFromServer { + updatingTitleFromServer = false } } - ZStack(alignment: .topLeading) { - if post.body.count == 0 { - Text("Write…") - .font(Font(bodyTextStyle)) - .foregroundColor(Color(UIColor.placeholderText)) - .padding(.horizontal, 4) - .padding(.vertical, 8) - .accessibilityHidden(true) + MultilineTextField( + "Write...", + text: $post.body, + font: bodyTextStyle, + isFirstResponder: $bodyIsFirstResponder + ) + .accessibilityLabel(Text("Body")) + .accessibilityHint(Text("Add or edit the body of your post")) + .onChange(of: post.body) { _ in + if post.status == PostStatus.published.rawValue && !updatingBodyFromServer { + post.status = PostStatus.edited.rawValue } - PostBodyTextView( - text: $post.body, - textStyle: $bodyTextStyle, - height: $bodyTextHeight, - isFirstResponder: $bodyIsFirstResponder, - lineSpacing: horizontalSizeClass == .compact ? lineSpacingMultiplier / 2 : lineSpacingMultiplier - ) - .frame(height: bodyFieldHeight) - .accessibilityLabel(Text("Body")) - .accessibilityHint(Text("Add or edit the body of your post")) - .onChange(of: post.body) { _ in - if post.status == PostStatus.published.rawValue && !updatingBodyFromServer { - post.status = PostStatus.edited.rawValue - } - if updatingBodyFromServer { - updatingBodyFromServer = false - } + if updatingBodyFromServer { + updatingBodyFromServer = false } } } @@ -116,4 +76,9 @@ struct PostTextEditingView: View { self.bodyTextStyle = UIFont(name: appearance.rawValue, size: 17)! }) } + + private func didFinishEditingTitle() { + self.titleIsFirstResponder = false + self.bodyIsFirstResponder = true + } } diff --git a/iOS/PostEditor/PostTitleTextView.swift b/iOS/PostEditor/PostTitleTextView.swift deleted file mode 100644 index 705bcf4..0000000 --- a/iOS/PostEditor/PostTitleTextView.swift +++ /dev/null @@ -1,129 +0,0 @@ -// Based on https://lostmoa.com/blog/DynamicHeightForTextFieldInSwiftUI and https://stackoverflow.com/a/56508132 - -import SwiftUI - -class PostTitleCoordinator: NSObject, UITextViewDelegate, NSLayoutManagerDelegate { - @Binding var text: String - @Binding var isFirstResponder: Bool - var lineSpacingMultiplier: CGFloat - var didBecomeFirstResponder: Bool = false - var postTitleTextView: PostTitleTextView - - weak var textView: UITextView? - - init( - _ textView: PostTitleTextView, - text: Binding, - isFirstResponder: Binding, - lineSpacingMultiplier: CGFloat - ) { - self.postTitleTextView = textView - _text = text - _isFirstResponder = isFirstResponder - self.lineSpacingMultiplier = lineSpacingMultiplier - } - - func textViewDidChange(_ textView: UITextView) { - DispatchQueue.main.async { - self.postTitleTextView.text = textView.text ?? "" - } - } - - func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - if text == "\n" { - self.isFirstResponder.toggle() - self.didBecomeFirstResponder = false - return false - } - return true - } - - func layoutManager( - _ layoutManager: NSLayoutManager, - didCompleteLayoutFor textContainer: NSTextContainer?, - atEnd layoutFinishedFlag: Bool - ) { - DispatchQueue.main.async { - guard let view = self.textView else { return } - let size = view.sizeThatFits(view.bounds.size) - if self.postTitleTextView.height != size.height { - self.postTitleTextView.height = size.height - } - } - } - - func layoutManager( - _ layoutManager: NSLayoutManager, - lineSpacingAfterGlyphAt glyphIndex: Int, - withProposedLineFragmentRect rect: CGRect - ) -> CGFloat { - // HACK: - This seems to be the only way to get line spacing to update dynamically on iPad - // when switching between full-screen, split-screen, and slide-over views. - if let window = UIApplication.shared.windows.filter({ $0.isKeyWindow }).first { - // Get the width of the window to determine the size class - if window.frame.width < 600 { - // Use 0.25 multiplier for compact size class - return 17 * 0.25 - } else { - // Use 0.5 multiplier otherwise - return 17 * 0.5 - } - } else { - return 17 * lineSpacingMultiplier - } - } -} - -struct PostTitleTextView: UIViewRepresentable { - @Binding var text: String - @Binding var textStyle: UIFont - @Binding var height: CGFloat - @Binding var isFirstResponder: Bool - @State var lineSpacing: CGFloat - - func makeUIView(context: UIViewRepresentableContext) -> UITextView { - let textView = UITextView() - - textView.isEditable = true - textView.isUserInteractionEnabled = true - textView.isScrollEnabled = true - textView.alwaysBounceVertical = false - - context.coordinator.textView = textView - textView.delegate = context.coordinator - textView.layoutManager.delegate = context.coordinator - - let font = textStyle - let fontMetrics = UIFontMetrics(forTextStyle: .largeTitle) - textView.font = fontMetrics.scaledFont(for: font) - - textView.backgroundColor = UIColor.clear - - return textView - } - - func makeCoordinator() -> PostTitleCoordinator { - return Coordinator( - self, - text: $text, - isFirstResponder: $isFirstResponder, - lineSpacingMultiplier: lineSpacing - ) - } - - func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext) { - if uiView.text != text { - uiView.text = text - } - - let font = textStyle - let fontMetrics = UIFontMetrics(forTextStyle: .largeTitle) - uiView.font = fontMetrics.scaledFont(for: font) - - // We don't want the text field to become first responder every time SwiftUI refreshes the view. - if isFirstResponder && !context.coordinator.didBecomeFirstResponder { - uiView.becomeFirstResponder() - context.coordinator.didBecomeFirstResponder = true - } - } -}