Last active
September 22, 2021 14:05
-
-
Save Lavmint/80991b17144f06ba913fed9aee2c51cf to your computer and use it in GitHub Desktop.
SwiftUI EditingView + MultilineTextField
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
import Combine | |
struct ContentView: View { | |
struct _State { | |
var text = "" | |
} | |
@State var state = _State() | |
var body: some View { | |
VStack { | |
EditingView { | |
VStack(alignment: .leading) { | |
Button(action: self.endEditing, label: { | |
Text("Hide keyboard:") | |
}) | |
Spacer() | |
Text("Something static here...") | |
MultilineTextField(placeholder: "Start typing...", text: $state.text) | |
Text("Bottom") | |
} | |
.padding() | |
.background(Color.orange) | |
} | |
Text("Simple text") | |
.frame(maxWidth: .infinity) | |
.padding(30) | |
.background(Color.blue) | |
} | |
} | |
} | |
struct EditingView<Content: View>: View { | |
struct _State { | |
var animatedHeight: CGFloat? | |
var height: CGFloat? | |
} | |
@State var state = _State() | |
let content: Content | |
@inlinable init(@ViewBuilder _ builder: () -> Content) { | |
content = builder() | |
} | |
var body: some View { | |
GeometryReader { (geo: GeometryProxy) in | |
ZStack { | |
ScrollView(.vertical, showsIndicators: true) { | |
self.content | |
.frame(minHeight: self.state.height ?? geo.size.height) | |
} | |
.frame(height: self.state.animatedHeight) | |
.frame(maxHeight: .infinity, alignment: .top) | |
.onReceive(Keyboard.willChangeFrame) { (notification) in | |
self.height(geo: geo, notification: notification) | |
} | |
} | |
} | |
.onTapGesture(perform: endEditing) | |
} | |
func height(geo: GeometryProxy, notification: Notification) { | |
let content = geo.frame(in: .global) | |
let bottom = content.origin.y + content.height | |
let offset = bottom - notification.keyboardRectEnd.origin.y | |
let height = notification.keyboardGoesUp ? geo.size.height - offset : geo.size.height | |
self.state.height = height | |
withAnimation(.linear(duration: notification.keyboardAnimationDuration)) { | |
self.state.animatedHeight = height | |
} | |
} | |
} | |
extension View { | |
func endEditing() { | |
UIApplication.shared.sendAction( | |
#selector(UIResponder.resignFirstResponder), | |
to: nil, | |
from: nil, | |
for: nil | |
) | |
} | |
} | |
struct MultilineTextField: View { | |
struct _State { | |
var height: CGFloat? | |
} | |
@State var state = _State() | |
let placeholder: String | |
@Binding var text: String | |
let placeholderColor: UIColor = .gray | |
let font: UIFont = UIFont.systemFont(ofSize: 30) | |
let textColor: UIColor = .black | |
var body: some View { | |
GeometryReader { (geo: GeometryProxy) in | |
self.content(with: geo) | |
} | |
.frame(height: state.height) | |
} | |
func content(with geo: GeometryProxy) -> some View { | |
ZStack(alignment: .leading) { | |
if text.isEmpty { | |
Text(placeholder) | |
.foregroundColor(.init(placeholderColor)) | |
.font(.init(font)) | |
.frame(maxWidth: .infinity, alignment: .leading) | |
} | |
textView(with: geo) | |
} | |
} | |
func textView(with geo: GeometryProxy) -> some View { | |
TextView( | |
make: { coordinator in | |
let textView = UITextView() | |
textView.backgroundColor = UIColor.clear | |
textView.delegate = coordinator | |
textView.isScrollEnabled = false | |
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) | |
textView.textContainerInset = .zero | |
textView.textContainer.lineFragmentPadding = 0 | |
textView.textColor = self.textColor | |
textView.font = self.font | |
coordinator.text = self.$text | |
coordinator.height = self.$state.height | |
return textView | |
}, | |
update: { uiView, coordinator in | |
if self.$text.wrappedValue != uiView.text { | |
uiView.text = self.$text.wrappedValue | |
} | |
coordinator.width = geo.size.width | |
coordinator.adjustHeight(view: uiView) | |
}) | |
.frame(height: state.height) | |
} | |
} | |
struct TextView: UIViewRepresentable { | |
typealias UIViewType = UITextView | |
let make: (Coordinator) -> UIViewType | |
let update: (UIViewType, Coordinator) -> Void | |
func makeCoordinator() -> TextView.Coordinator { | |
return Coordinator() | |
} | |
func makeUIView(context: UIViewRepresentableContext<TextView>) -> UIViewType { | |
return make(context.coordinator) | |
} | |
func updateUIView(_ uiView: UIViewType, context: UIViewRepresentableContext<TextView>) { | |
update(uiView, context.coordinator) | |
} | |
class Coordinator: NSObject, UITextViewDelegate { | |
var text: Binding<String> = .constant("") | |
var width: CGFloat = 0 | |
var height: Binding<CGFloat?> = .constant(nil) | |
var cursor: Binding<CGFloat?> = .constant(nil) | |
func textViewDidChange(_ textView: UITextView) { | |
if text.wrappedValue != textView.text { | |
text.wrappedValue = textView.text | |
} | |
adjustHeight(view: textView) | |
} | |
func textViewDidChangeSelection(_ textView: UITextView) { | |
OperationQueue.main.addOperation { [weak self] in | |
self?.cursor.wrappedValue = self?.absoleteCursor(view: textView) | |
} | |
} | |
func adjustHeight(view: UITextView) { | |
let bounds = CGSize(width: width, height: .infinity) | |
let height = view.sizeThatFits(bounds).height | |
OperationQueue.main.addOperation { [weak self] in | |
self?.height.wrappedValue = height | |
} | |
} | |
func absoleteCursor(view: UITextView) -> CGFloat? { | |
guard let range = view.selectedTextRange else { | |
return nil | |
} | |
let caretRect = view.caretRect(for: range.end) | |
let windowRect = view.convert(caretRect, to: nil) | |
return windowRect.origin.y + windowRect.height | |
} | |
} | |
} | |
enum Keyboard { | |
static let willShow = NotificationCenter.default | |
.publisher(for: UIResponder.keyboardWillShowNotification) | |
.receive(on: OperationQueue.main) | |
static let didShow = NotificationCenter.default | |
.publisher(for: UIResponder.keyboardDidShowNotification) | |
.receive(on: OperationQueue.main) | |
static let willHide = NotificationCenter.default | |
.publisher(for: UIResponder.keyboardWillHideNotification) | |
.receive(on: OperationQueue.main) | |
static let didHide = NotificationCenter.default | |
.publisher(for: UIResponder.keyboardDidHideNotification) | |
.receive(on: OperationQueue.main) | |
static let willChangeFrame = NotificationCenter.default | |
.publisher(for: UIResponder.keyboardWillChangeFrameNotification) | |
.receive(on: OperationQueue.main) | |
static let didChangeFrame = NotificationCenter.default | |
.publisher(for: UIResponder.keyboardDidChangeFrameNotification) | |
.receive(on: OperationQueue.main) | |
} | |
public extension Notification { | |
var keyboardRectBegin: CGRect { | |
return (userInfo![UIResponder.keyboardFrameBeginUserInfoKey] as! NSValue).cgRectValue | |
} | |
var keyboardRectEnd: CGRect { | |
return (userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! NSValue).cgRectValue | |
} | |
var keyboardGoesDown: Bool { | |
let beginY = keyboardRectBegin.origin.y | |
let endY = keyboardRectEnd.origin.y | |
return beginY < endY | |
} | |
var keyboardGoesUp: Bool { | |
return !keyboardGoesDown | |
} | |
var keyboardHeight: CGFloat { | |
if keyboardGoesDown { // going down | |
return 0 | |
} else { // otherwise | |
return keyboardRectEnd.size.height | |
} | |
} | |
var keyboardAnimationDuration: Double { | |
return userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0.25 | |
} | |
var keyboardAnimationOptions: UIView.AnimationOptions { | |
if let curveValue = (userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue { | |
return [UIView.AnimationOptions(rawValue: curveValue << 16), .beginFromCurrentState] | |
} | |
return [] | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment