diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index e77fa11..6831b7c 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -7,7 +7,7 @@ struct ContentView: View { NavigationView { SidebarView() - PostListView(selectedCollection: nil, showAllPosts: true) + PostListView(selectedCollection: nil, showAllPosts: model.account.isLoggedIn) Text("Select a post, or create a new local draft.") .foregroundColor(.secondary) diff --git a/Shared/PostCollection/CollectionListView.swift b/Shared/PostCollection/CollectionListView.swift index 09d1170..23a4aa5 100644 --- a/Shared/PostCollection/CollectionListView.swift +++ b/Shared/PostCollection/CollectionListView.swift @@ -10,10 +10,10 @@ struct CollectionListView: View { var body: some View { List { - NavigationLink(destination: PostListView(selectedCollection: nil, showAllPosts: true)) { - Text("All Posts") - } if model.account.isLoggedIn { + NavigationLink(destination: PostListView(selectedCollection: nil, showAllPosts: true)) { + Text("All Posts") + } NavigationLink(destination: PostListView(selectedCollection: nil, showAllPosts: false)) { Text(model.account.server == "https://write.as" ? "Anonymous" : "Drafts") } @@ -26,6 +26,10 @@ struct CollectionListView: View { } } } + } else { + NavigationLink(destination: PostListView(selectedCollection: nil, showAllPosts: false)) { + Text("Drafts") + } } } .navigationTitle( diff --git a/Shared/PostEditor/PostEditorModel.swift b/Shared/PostEditor/PostEditorModel.swift index ae08d75..8d83713 100644 --- a/Shared/PostEditor/PostEditorModel.swift +++ b/Shared/PostEditor/PostEditorModel.swift @@ -3,8 +3,8 @@ import CoreData enum PostAppearance: String { case sans = "OpenSans-Regular" - case mono = "Hack" - case serif = "Lora" + case mono = "Hack-Regular" + case serif = "Lora-Regular" } struct PostEditorModel { diff --git a/WriteFreely-MultiPlatform.xcodeproj/project.pbxproj b/WriteFreely-MultiPlatform.xcodeproj/project.pbxproj index 4bc5d99..8e4d929 100644 --- a/WriteFreely-MultiPlatform.xcodeproj/project.pbxproj +++ b/WriteFreely-MultiPlatform.xcodeproj/project.pbxproj @@ -53,6 +53,8 @@ 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 */; }; 17B3E965250FAA9000EE9748 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 17B3E964250FAA9000EE9748 /* LaunchScreen.storyboard */; }; 17B5103B2515448D00E9631F /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 17B5103A2515448D00E9631F /* Credits.rtf */; }; 17B996D82502D23E0017B536 /* WFAPost+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B996D62502D23E0017B536 /* WFAPost+CoreDataClass.swift */; }; @@ -141,6 +143,8 @@ 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 = ""; }; 17B3E964250FAA9000EE9748 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; 17B5103A2515448D00E9631F /* Credits.rtf */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = ""; }; 17B996D62502D23E0017B536 /* WFAPost+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WFAPost+CoreDataClass.swift"; sourceTree = SOURCE_ROOT; }; @@ -294,6 +298,8 @@ 1756AE7624CB2EDD00FD7257 /* PostEditorView.swift */, 173E19D0254318F600440F0F /* RemoteChangePromptView.swift */, 173E19E2254329CC00440F0F /* PostTextEditingView.swift */, + 17AD0A5D25489E810057D763 /* PostTitleTextView.swift */, + 17AD0A6325489E900057D763 /* PostBodyTextView.swift */, ); path = PostEditor; sourceTree = ""; @@ -694,6 +700,8 @@ 170DFA34251BBC44001D82A0 /* PostEditorModel.swift in Sources */, 17120DAC24E1B99F002B9F6C /* AccountLoginView.swift in Sources */, 17480CA5251272EE00EB7765 /* Bundle+AppVersion.swift in Sources */, + 17AD0A6425489E900057D763 /* PostBodyTextView.swift in Sources */, + 17AD0A5E25489E810057D763 /* PostTitleTextView.swift in Sources */, 17120DA924E1B2F5002B9F6C /* AccountLogoutView.swift in Sources */, 171BFDFA24D4AF8300888236 /* CollectionListView.swift in Sources */, 1756DBB324FECDBB00207AB8 /* PostEditorStatusToolbarView.swift in Sources */, diff --git a/iOS/PostEditor/PostBodyTextView.swift b/iOS/PostEditor/PostBodyTextView.swift new file mode 100644 index 0000000..4dfa0b2 --- /dev/null +++ b/iOS/PostEditor/PostBodyTextView.swift @@ -0,0 +1,124 @@ +// Based on https://stackoverflow.com/a/56508132/1234545 and https://stackoverflow.com/a/48360549/1234545 + +import SwiftUI + +class PostBodyCoordinator: NSObject, UITextViewDelegate, NSLayoutManagerDelegate { + @Binding var text: String + @Binding var isFirstResponder: Bool + @Binding var currentTextPosition: UITextRange? + var lineSpacingMultiplier: CGFloat + var didBecomeFirstResponder: Bool = false + var postBodyTextView: PostBodyTextView + + weak var textView: UITextView? + + init( + _ textView: PostBodyTextView, + text: Binding, + isFirstResponder: Binding, + currentTextPosition: Binding, + lineSpacingMultiplier: CGFloat + ) { + self.postBodyTextView = textView + _text = text + _isFirstResponder = isFirstResponder + self.lineSpacingMultiplier = lineSpacingMultiplier + _currentTextPosition = currentTextPosition + } + + func textViewDidChange(_ textView: UITextView) { + DispatchQueue.main.async { + self.postBodyTextView.text = textView.text ?? "" + } + } + + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + self.currentTextPosition = textView.selectedTextRange + return true + } + + func textViewDidBeginEditing(_ textView: UITextView) { + if let textPosition = currentTextPosition { + textView.selectedTextRange = textPosition + } + } + + func textViewDidEndEditing(_ textView: UITextView) { + self.isFirstResponder = false + self.didBecomeFirstResponder = false + self.currentTextPosition = textView.selectedTextRange + } + + 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 isFirstResponder: Bool + @Binding var currentTextPosition: UITextRange? + @State var lineSpacing: CGFloat + + func makeUIView(context: UIViewRepresentableContext) -> UITextView { + let textView = UITextView(frame: .zero) + + 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() -> PostBodyCoordinator { + return Coordinator( + self, + text: $text, + isFirstResponder: $isFirstResponder, + currentTextPosition: $currentTextPosition, + lineSpacingMultiplier: lineSpacing + ) + } + + func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext) { + 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/PostEditorView.swift b/iOS/PostEditor/PostEditorView.swift index 954798d..a5699f4 100644 --- a/iOS/PostEditor/PostEditorView.swift +++ b/iOS/PostEditor/PostEditorView.swift @@ -3,6 +3,7 @@ import SwiftUI struct PostEditorView: View { @EnvironmentObject var model: WriteFreelyModel @Environment(\.horizontalSizeClass) var horizontalSizeClass + @Environment(\.managedObjectContext) var moc @Environment(\.presentationMode) var presentationMode @ObservedObject var post: WFAPost diff --git a/iOS/PostEditor/PostTextEditingView.swift b/iOS/PostEditor/PostTextEditingView.swift index c24530e..90a1e5c 100644 --- a/iOS/PostEditor/PostTextEditingView.swift +++ b/iOS/PostEditor/PostTextEditingView.swift @@ -6,7 +6,13 @@ struct PostTextEditingView: View { @Binding var updatingTitleFromServer: Bool @Binding var updatingBodyFromServer: Bool @State private var appearance: PostAppearance = .serif - private let bodyLineSpacingMultiplier: CGFloat = 0.5 + @State private var titleTextStyle: UIFont = UIFont(name: "Lora-Regular", size: 26)! + @State private var titleTextHeight: 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 + @State private var bodyCursorPosition: UITextRange? + private let lineSpacingMultiplier: CGFloat = 0.5 init( post: ObservedObject, @@ -19,41 +25,69 @@ struct PostTextEditingView: View { UITextView.appearance().backgroundColor = .clear } + var titleFieldHeight: CGFloat { + let minHeight: CGFloat = 50 + if titleTextHeight < minHeight { + return minHeight + } + return titleTextHeight + } + var body: some View { VStack { - TextField("Title (optional)", text: $post.title) - .font(.custom(appearance.rawValue, size: 26, relativeTo: .largeTitle)) - .padding(.horizontal, 4) - .onChange(of: post.title) { _ in - if post.status == PostStatus.published.rawValue && !updatingTitleFromServer { - post.status = PostStatus.edited.rawValue - } - } ZStack(alignment: .topLeading) { - if post.body.count == 0 { - Text("Write…") - .font(.custom(appearance.rawValue, size: 17, relativeTo: .body)) + if post.title.count == 0 { + Text("Title (optional)") + .font(Font(titleTextStyle)) .foregroundColor(Color(UIColor.placeholderText)) .padding(.horizontal, 4) .padding(.vertical, 8) } - TextEditor(text: $post.body) - .font(.custom(appearance.rawValue, size: 17, relativeTo: .body)) - .lineSpacing( - 17 * ( - horizontalSizeClass == .compact ? bodyLineSpacingMultiplier / 2 : bodyLineSpacingMultiplier - ) - ) - .onChange(of: post.body) { _ in - if post.status == PostStatus.published.rawValue && !updatingBodyFromServer { - post.status = PostStatus.edited.rawValue - } - if updatingBodyFromServer { - updatingBodyFromServer = false - } + PostTitleTextView( + text: $post.title, + textStyle: $titleTextStyle, + height: $titleTextHeight, + isFirstResponder: $titleIsFirstResponder, + lineSpacing: horizontalSizeClass == .compact ? lineSpacingMultiplier / 2 : lineSpacingMultiplier + ) + .frame(height: titleFieldHeight) + .onChange(of: post.title) { _ in + if post.status == PostStatus.published.rawValue && !updatingTitleFromServer { + post.status = PostStatus.edited.rawValue } + 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) + } + PostBodyTextView( + text: $post.body, + textStyle: $bodyTextStyle, + isFirstResponder: $bodyIsFirstResponder, + currentTextPosition: $bodyCursorPosition, + lineSpacing: horizontalSizeClass == .compact ? lineSpacingMultiplier / 2 : lineSpacingMultiplier + ) + .onChange(of: post.body) { _ in + if post.status == PostStatus.published.rawValue && !updatingBodyFromServer { + post.status = PostStatus.edited.rawValue + } + if updatingBodyFromServer { + updatingBodyFromServer = false + } + } } } + .onChange(of: titleIsFirstResponder, perform: { _ in + self.bodyIsFirstResponder.toggle() + }) .onAppear(perform: { switch post.appearance { case "sans": @@ -63,6 +97,8 @@ struct PostTextEditingView: View { default: self.appearance = .serif } + self.titleTextStyle = UIFont(name: appearance.rawValue, size: 26)! + self.bodyTextStyle = UIFont(name: appearance.rawValue, size: 17)! }) } } diff --git a/iOS/PostEditor/PostTitleTextView.swift b/iOS/PostEditor/PostTitleTextView.swift new file mode 100644 index 0000000..53d8bb8 --- /dev/null +++ b/iOS/PostEditor/PostTitleTextView.swift @@ -0,0 +1,127 @@ +// Based on https://lostmoa.com/blog/DynamicHeightForTextFieldInSwiftUI and https://stackoverflow.com/a/56508132/1234545 + +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) { + 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 + } + } +}