Improve editor scrolling on ios (#229)

* Unset isScrollEnabled property on UITextViews

* Begin implementing common MultilineTextView

* Remove legacy text views

* Fix firstResponder issues

* Bump version and build number and update change log

* Fix smart-dashes replacement in MultilineTextView

* Wait 10ms before navigating to the editor after creating a new post

* Wait before navigating to editor after creating a new post

* Bump build number
This commit is contained in:
Angelo Stavrow 2022-11-03 06:56:11 -04:00 committed by GitHub
parent ceb7eaa2ac
commit f256996161
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 212 additions and 351 deletions

View File

@ -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

View File

@ -47,8 +47,10 @@ struct PostListView: View {
let managedPost = model.editor.generateNewLocalPost(withFont: model.preferences.font)
withAnimation {
self.model.showAllPosts = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.model.selectedPost = managedPost
}
}
}, label: {
ZStack {
Image("does.not.exist")

View File

@ -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) {

View File

@ -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 = "<group>"; };
17A5389124DDED0000DEFF9A /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; };
17A67CAE251A5DD7002F163D /* PostEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostEditorView.swift; sourceTree = "<group>"; };
17AD0A5D25489E810057D763 /* PostTitleTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostTitleTextView.swift; sourceTree = "<group>"; };
17AD0A6325489E900057D763 /* PostBodyTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostBodyTextView.swift; sourceTree = "<group>"; };
17B37C4A25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WriteFreelyModel+Keychain.swift"; sourceTree = "<group>"; };
17B37C5525C8679800FE75E9 /* WriteFreelyModel+API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WriteFreelyModel+API.swift"; sourceTree = "<group>"; };
17B37C5C25C8698900FE75E9 /* WriteFreelyModel+APIHandlers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WriteFreelyModel+APIHandlers.swift"; sourceTree = "<group>"; };
@ -270,6 +267,7 @@
17DFDE85251D309400A25F31 /* Lora-Cyrillic-OFL.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "Lora-Cyrillic-OFL.txt"; sourceTree = "<group>"; };
17DFDE86251D309400A25F31 /* OpenSans-License.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "OpenSans-License.txt"; sourceTree = "<group>"; };
17E5DF892543610700DCDC9B /* PostTextEditingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostTextEditingView.swift; sourceTree = "<group>"; };
375A67E728FC555C007A1AC0 /* MultilineTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultilineTextView.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@ -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 */

View File

@ -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<UITextViewWrapper>) -> 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<UITextViewWrapper>) {
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<CGFloat>) {
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<String>
var calculatedHeight: Binding<CGFloat>
var onDone: (() -> Void)?
init(
text: Binding<String>,
height: Binding<CGFloat>,
isFirstResponder: Binding<Bool>,
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<String> {
Binding<String>(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<String>,
font: UIFont,
isFirstResponder: Binding<Bool>,
onCommit: (() -> Void)? = nil
) {
self.placeholder = placeholder
self.onCommit = onCommit
self.textStyle = font
self._isFirstResponder = isFirstResponder
self._text = text
self._showingPlaceholder = State<Bool>(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)
}
}
}
}

View File

@ -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<String>,
isFirstResponder: Binding<Bool>,
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<PostBodyTextView>) -> 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<PostBodyTextView>) {
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
}
}
}

View File

@ -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,42 +24,17 @@ 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)
}
PostTitleTextView(
MultilineTextField(
"Title (optional)",
text: $post.title,
textStyle: $titleTextStyle,
height: $titleTextHeight,
font: titleTextStyle,
isFirstResponder: $titleIsFirstResponder,
lineSpacing: horizontalSizeClass == .compact ? lineSpacingMultiplier / 2 : lineSpacingMultiplier
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"))
.frame(height: titleFieldHeight)
.onChange(of: post.title) { _ in
if post.status == PostStatus.published.rawValue && !updatingTitleFromServer {
post.status = PostStatus.edited.rawValue
@ -70,24 +43,12 @@ struct PostTextEditingView: View {
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)
}
PostBodyTextView(
MultilineTextField(
"Write...",
text: $post.body,
textStyle: $bodyTextStyle,
height: $bodyTextHeight,
isFirstResponder: $bodyIsFirstResponder,
lineSpacing: horizontalSizeClass == .compact ? lineSpacingMultiplier / 2 : lineSpacingMultiplier
font: bodyTextStyle,
isFirstResponder: $bodyIsFirstResponder
)
.frame(height: bodyFieldHeight)
.accessibilityLabel(Text("Body"))
.accessibilityHint(Text("Add or edit the body of your post"))
.onChange(of: post.body) { _ in
@ -99,7 +60,6 @@ struct PostTextEditingView: View {
}
}
}
}
.onChange(of: titleIsFirstResponder, perform: { _ in
self.bodyIsFirstResponder.toggle()
})
@ -116,4 +76,9 @@ struct PostTextEditingView: View {
self.bodyTextStyle = UIFont(name: appearance.rawValue, size: 17)!
})
}
private func didFinishEditingTitle() {
self.titleIsFirstResponder = false
self.bodyIsFirstResponder = true
}
}

View File

@ -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<String>,
isFirstResponder: Binding<Bool>,
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<PostTitleTextView>) -> 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<PostTitleTextView>) {
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
}
}
}