mirror of
https://github.com/writeas/writefreely-swiftui-multiplatform.git
synced 2024-11-15 01:11:02 +00:00
Merge pull request #114 from writeas/implement-responder-chain-in-editor
Implement responder chain in editor
This commit is contained in:
commit
d09ddc35a2
@ -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 {
|
||||
|
@ -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 = "<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>"; };
|
||||
17B3E964250FAA9000EE9748 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
17B5103A2515448D00E9631F /* Credits.rtf */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = "<group>"; };
|
||||
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 = "<group>";
|
||||
@ -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 */,
|
||||
|
124
iOS/PostEditor/PostBodyTextView.swift
Normal file
124
iOS/PostEditor/PostBodyTextView.swift
Normal file
@ -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<String>,
|
||||
isFirstResponder: Binding<Bool>,
|
||||
currentTextPosition: Binding<UITextRange?>,
|
||||
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<PostBodyTextView>) -> 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<PostBodyTextView>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
@ -3,7 +3,6 @@ import SwiftUI
|
||||
struct PostEditorView: View {
|
||||
@EnvironmentObject var model: WriteFreelyModel
|
||||
@Environment(\.managedObjectContext) var moc
|
||||
@Environment(\.horizontalSizeClass) var horizontalSizeClass
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
|
||||
@ObservedObject var post: WFAPost
|
||||
|
@ -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<WFAPost>,
|
||||
@ -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)!
|
||||
})
|
||||
}
|
||||
}
|
||||
|
127
iOS/PostEditor/PostTitleTextView.swift
Normal file
127
iOS/PostEditor/PostTitleTextView.swift
Normal file
@ -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<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>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user