diff --git a/Vernissage.xcodeproj/project.pbxproj b/Vernissage.xcodeproj/project.pbxproj index 142a759..899ca31 100644 --- a/Vernissage.xcodeproj/project.pbxproj +++ b/Vernissage.xcodeproj/project.pbxproj @@ -76,6 +76,8 @@ F87AEB922986C44E00434FB6 /* AuthorizationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87AEB912986C44E00434FB6 /* AuthorizationSession.swift */; }; F87AEB942986C51B00434FB6 /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87AEB932986C51B00434FB6 /* AppConstants.swift */; }; F87AEB972986D16D00434FB6 /* AuthorisationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87AEB962986D16D00434FB6 /* AuthorisationError.swift */; }; + F8864CE929ACAF820020C534 /* TextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8864CE829ACAF820020C534 /* TextView.swift */; }; + F8864CEB29ACBAA80020C534 /* TextFieldViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8864CEA29ACBAA80020C534 /* TextFieldViewModel.swift */; }; F886F257297859E300879356 /* CacheImageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F886F256297859E300879356 /* CacheImageService.swift */; }; F88ABD9229686F1C004EF61E /* MemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88ABD9129686F1C004EF61E /* MemoryCache.swift */; }; F88ABD9429687CA4004EF61E /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88ABD9329687CA4004EF61E /* ComposeView.swift */; }; @@ -218,6 +220,8 @@ F87AEB912986C44E00434FB6 /* AuthorizationSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationSession.swift; sourceTree = ""; }; F87AEB932986C51B00434FB6 /* AppConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = ""; }; F87AEB962986D16D00434FB6 /* AuthorisationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorisationError.swift; sourceTree = ""; }; + F8864CE829ACAF820020C534 /* TextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextView.swift; sourceTree = ""; }; + F8864CEA29ACBAA80020C534 /* TextFieldViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldViewModel.swift; sourceTree = ""; }; F886F256297859E300879356 /* CacheImageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheImageService.swift; sourceTree = ""; }; F88ABD9129686F1C004EF61E /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = ""; }; F88ABD9329687CA4004EF61E /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; @@ -460,6 +464,7 @@ F876418A298AC1B80057D362 /* NoDataView.swift */, F86FB554298BF83F000131F0 /* FavouriteTouch.swift */, F8FA991F299FDDC3007AB130 /* TextInputField.swift */, + F8864CE829ACAF820020C534 /* TextView.swift */, ); path = Widgets; sourceTree = ""; @@ -687,6 +692,7 @@ children = ( F878842029A494E3003CFAD2 /* Subviews */, F88ABD9329687CA4004EF61E /* ComposeView.swift */, + F8864CEA29ACBAA80020C534 /* TextFieldViewModel.swift */, ); path = ComposeView; sourceTree = ""; @@ -822,6 +828,7 @@ F85D497929640B9D00751DF7 /* ImagesCarousel.swift in Sources */, F8C5E55F2988E92600ADF6A7 /* AccountModel.swift in Sources */, F89D6C3F29716E41001DA3D4 /* Theme.swift in Sources */, + F8864CE929ACAF820020C534 /* TextView.swift in Sources */, F89AC00529A1F9B500F4159F /* AppMetadata.swift in Sources */, F8CC95CE2970761D00C9C2AC /* TintColor.swift in Sources */, F89992CC296D9231005994BF /* StatusModel.swift in Sources */, @@ -898,6 +905,7 @@ F8FA9920299FDDC3007AB130 /* TextInputField.swift in Sources */, F86A4303299A9AF500DF7645 /* TipsStore.swift in Sources */, F8C5E56229892CC300ADF6A7 /* FirstAppear.swift in Sources */, + F8864CEB29ACBAA80020C534 /* TextFieldViewModel.swift in Sources */, F8C14394296AF21B001FE31D /* Double+Round.swift in Sources */, F83CBEFB298298A1002972C8 /* ImageCarouselPicture.swift in Sources */, F89A46DC296EAACE0062125F /* SettingsView.swift in Sources */, @@ -1035,7 +1043,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 36; + CURRENT_PROJECT_VERSION = 37; DEVELOPMENT_ASSET_PATHS = "\"Vernissage/Preview Content\""; DEVELOPMENT_TEAM = B2U9FEKYP8; ENABLE_PREVIEWS = YES; @@ -1072,7 +1080,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 36; + CURRENT_PROJECT_VERSION = 37; DEVELOPMENT_ASSET_PATHS = "\"Vernissage/Preview Content\""; DEVELOPMENT_TEAM = B2U9FEKYP8; ENABLE_PREVIEWS = YES; diff --git a/Vernissage/Assets.xcassets/KeyboardToolbar.colorset/Contents.json b/Vernissage/Assets.xcassets/KeyboardToolbar.colorset/Contents.json new file mode 100644 index 0000000..026cda1 --- /dev/null +++ b/Vernissage/Assets.xcassets/KeyboardToolbar.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "247", + "green" : "247", + "red" : "247" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "42", + "green" : "40", + "red" : "40" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Vernissage/Extensions/Color+Assets.swift b/Vernissage/Extensions/Color+Assets.swift index 6bd7b0e..1445ecf 100644 --- a/Vernissage/Extensions/Color+Assets.swift +++ b/Vernissage/Extensions/Color+Assets.swift @@ -14,6 +14,7 @@ extension Color { static let mainTextColor = Color("MainTextColor") static let selectedRowColor = Color("SelectedRowColor") static let viewBackgroundColor = Color("ViewBackgroundColor") + static let keyboardToolbarColor = Color("KeyboardToolbar") static let viewTextColor = Color("ViewTextColor") static let accentColor1 = Color("AccentColor1") static let accentColor2 = Color("AccentColor2") diff --git a/Vernissage/Views/ComposeView/ComposeView.swift b/Vernissage/Views/ComposeView/ComposeView.swift index abe49fe..f5a77ee 100644 --- a/Vernissage/Views/ComposeView/ComposeView.swift +++ b/Vernissage/Views/ComposeView/ComposeView.swift @@ -17,7 +17,7 @@ struct ComposeView: View { @Environment(\.dismiss) private var dismiss @State var statusViewModel: StatusModel? - @State private var text = String.empty() + @State private var isSensitive = false @State private var spoilerText = String.empty() @State private var commentsDisabled = false @@ -60,167 +60,171 @@ struct ComposeView: View { } private let contentWidth = Int(UIScreen.main.bounds.width) - 50 - private let keyboardFontSize = 14.0 + private let keyboardFontSize = 22.0 + + @StateObject private var textFieldViewModel: TextFieldViewModel + + public init(statusViewModel: StatusModel? = nil) { + _textFieldViewModel = StateObject(wrappedValue: .init()) + self.statusViewModel = statusViewModel + } var body: some View { NavigationStack { NavigationView { - ScrollView { - VStack (alignment: .leading){ - if self.isSensitive { - TextField("Write content warning", text: $spoilerText, axis: .vertical) - .padding(8) - .lineLimit(1...2) - .focused($focusedField, equals: .spoilerText) - .keyboardType(.default) - .background(Color.dangerColor.opacity(0.4)) - } - - if self.commentsDisabled { - HStack { - Spacer() - Text("Comments will be disabled") - .textCase(.uppercase) - .font(.caption2) - .foregroundColor(.dangerColor) + ZStack(alignment: .bottom) { + + ScrollView { + VStack (alignment: .leading){ + if self.isSensitive { + TextField("Write content warning", text: $spoilerText, axis: .vertical) + .padding(8) + .lineLimit(1...2) + .focused($focusedField, equals: .spoilerText) + .keyboardType(.default) + .background(Color.dangerColor.opacity(0.4)) } - .padding(.horizontal, 8) - } - - if let accountData = applicationState.account { - HStack { - UsernameRow( - accountId: accountData.id, - accountAvatar: accountData.avatar, - accountDisplayName: accountData.displayName, - accountUsername: accountData.username) - Spacer() - } - .padding(.horizontal, 8) - } - HStack { - Menu { - Button { - self.visibility = .pub - self.visibilityText = "Everyone" - self.visibilityImage = "globe.europe.africa" - } label: { - Label("Everyone", systemImage: "globe.europe.africa") - } - - Button { - self.visibility = .unlisted - self.visibilityText = "Unlisted" - self.visibilityImage = "lock.open" - } label: { - Label("Unlisted", systemImage: "lock.open") - } - - Button { - self.visibility = .priv - self.visibilityText = "Followers" - self.visibilityImage = "lock" - } label: { - Label("Followers", systemImage: "lock") - } - } label: { + if self.commentsDisabled { HStack { - Label(self.visibilityText, systemImage: self.visibilityImage) - Image(systemName: "chevron.down") + Spacer() + Text("Comments will be disabled") + .textCase(.uppercase) + .font(.caption2) + .foregroundColor(.dangerColor) } - .padding(.vertical, 4) .padding(.horizontal, 8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.accentColor, lineWidth: 1) - ) } - Spacer() - - if let name = self.place?.name, let country = self.place?.country { - Group { - Image(systemName: "mappin.and.ellipse") - Text("\(name), \(country)") + if let accountData = applicationState.account { + HStack { + UsernameRow( + accountId: accountData.id, + accountAvatar: accountData.avatar, + accountDisplayName: accountData.displayName, + accountUsername: accountData.username) + Spacer() } - .foregroundColor(.lightGrayColor) - .padding(.trailing, 8) + .padding(.horizontal, 8) } - } - .font(.footnote) - .padding(.horizontal, 8) - - - TextField(self.placeholder(), text: $text, axis: .vertical) + + HStack { + Menu { + Button { + self.visibility = .pub + self.visibilityText = "Everyone" + self.visibilityImage = "globe.europe.africa" + } label: { + Label("Everyone", systemImage: "globe.europe.africa") + } + + Button { + self.visibility = .unlisted + self.visibilityText = "Unlisted" + self.visibilityImage = "lock.open" + } label: { + Label("Unlisted", systemImage: "lock.open") + } + + Button { + self.visibility = .priv + self.visibilityText = "Followers" + self.visibilityImage = "lock" + } label: { + Label("Followers", systemImage: "lock") + } + } label: { + HStack { + Label(self.visibilityText, systemImage: self.visibilityImage) + Image(systemName: "chevron.down") + } + .padding(.vertical, 4) + .padding(.horizontal, 8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.accentColor, lineWidth: 1) + ) + } + + Spacer() + + if let name = self.place?.name, let country = self.place?.country { + Group { + Image(systemName: "mappin.and.ellipse") + Text("\(name), \(country)") + } + .foregroundColor(.lightGrayColor) + .padding(.trailing, 8) + } + } + .font(.footnote) + .padding(.horizontal, 8) + + + TextView($textFieldViewModel.text, getTextView: { textView in + self.textFieldViewModel.textView = textView + }) + .placeholder(self.placeholder()) .padding(.horizontal, 8) - .lineLimit(2...12) .focused($focusedField, equals: .content) - .keyboardType(.default) .onFirstAppear { self.focusedField = .content } - .onChange(of: self.text) { newValue in - self.refreshScreenState() - } - .toolbar { - self.keyboardToolbar() - } - - HStack(alignment: .center) { - LazyVGrid(columns: [GridItem(.adaptive(minimum:80))]) { - ForEach(self.photosAttachment, id: \.id) { photoAttachment in - ImageUploadView(photoAttachment: photoAttachment) { - self.showSheet = .photoDetails(photoAttachment) - } delete: { - self.photosAttachment = self.photosAttachment.filter({ item in - item != photoAttachment - }) - - self.selectedItems = self.selectedItems.filter({ item in - item != photoAttachment.photosPickerItem - }) - - self.refreshScreenState() - } upload: { - Task { - photoAttachment.error = nil - await self.upload(photoAttachment) + + HStack(alignment: .center) { + LazyVGrid(columns: [GridItem(.adaptive(minimum:80))]) { + ForEach(self.photosAttachment, id: \.id) { photoAttachment in + ImageUploadView(photoAttachment: photoAttachment) { + self.showSheet = .photoDetails(photoAttachment) + } delete: { + self.photosAttachment = self.photosAttachment.filter({ item in + item != photoAttachment + }) + + self.selectedItems = self.selectedItems.filter({ item in + item != photoAttachment.photosPickerItem + }) + self.refreshScreenState() + } upload: { + Task { + photoAttachment.error = nil + await self.upload(photoAttachment) + self.refreshScreenState() + } } } } } - } - .padding(8) - - if let status = self.statusViewModel { - HStack (alignment: .top) { - UserAvatar(accountAvatar: status.account.avatar, size: .comment) - - VStack (alignment: .leading, spacing: 0) { - HStack (alignment: .top) { - Text(statusViewModel?.account.displayNameWithoutEmojis ?? "") - .foregroundColor(.mainTextColor) - .font(.footnote) - .fontWeight(.bold) - - Spacer() - } - - MarkdownFormattedText(status.content.asMarkdown, withFontSize: 14, andWidth: contentWidth) - .environment(\.openURL, OpenURLAction { url in .handled }) - } - } .padding(8) - .background(Color.selectedRowColor) + + if let status = self.statusViewModel { + HStack (alignment: .top) { + UserAvatar(accountAvatar: status.account.avatar, size: .comment) + + VStack (alignment: .leading, spacing: 0) { + HStack (alignment: .top) { + Text(statusViewModel?.account.displayNameWithoutEmojis ?? "") + .foregroundColor(.mainTextColor) + .font(.footnote) + .fontWeight(.bold) + + Spacer() + } + + MarkdownFormattedText(status.content.asMarkdown, withFontSize: 14, andWidth: contentWidth) + .environment(\.openURL, OpenURLAction { url in .handled }) + } + } + .padding(8) + .background(Color.selectedRowColor) + } + + Spacer() } - - Spacer() } - } - .onTapGesture { - self.hideKeyboard() + + self.keyboardToolbar() } .frame(alignment: .topLeading) .toolbar { @@ -240,6 +244,9 @@ struct ComposeView: View { } } } + .onChange(of: self.textFieldViewModel.text) { newValue in + self.refreshScreenState() + } .onChange(of: self.selectedItems) { selectedItem in Task { await self.loadPhotos() @@ -266,10 +273,12 @@ struct ComposeView: View { .interactiveDismissDisabled(self.interactiveDismissDisabled) } - @ToolbarContentBuilder - private func keyboardToolbar() -> some ToolbarContent { - ToolbarItemGroup(placement: .keyboard) { - HStack(alignment: .center) { + private func keyboardToolbar() -> some View { + VStack(spacing: 0) { + Divider() + HStack(alignment: .center, spacing: 22) { + + Button { hideKeyboard() self.focusedField = .unknown @@ -277,7 +286,7 @@ struct ComposeView: View { } label: { Image(systemName: self.photosAreAttached ? "photo.fill.on.rectangle.fill" : "photo.on.rectangle") } - + Button { withAnimation(.easeInOut) { self.isSensitive.toggle() @@ -299,7 +308,7 @@ struct ComposeView: View { } label: { Image(systemName: self.commentsDisabled ? "person.2.slash" : "person.2.fill") } - + Button { if self.place != nil { withAnimation(.easeInOut) { @@ -313,24 +322,27 @@ struct ComposeView: View { } Button { - self.text.append("#") + self.textFieldViewModel.append(content: "#") } label: { Image(systemName: "number") } - + Button { - self.text.append("@") + self.textFieldViewModel.append(content: "@") } label: { Image(systemName: "at") } Spacer() - Text("\(self.applicationState.statusMaxCharacters - text.string.utf16.count)") - .foregroundColor(.lightGrayColor) + Text("\(self.applicationState.statusMaxCharacters - textFieldViewModel.text.string.utf16.count)") + .foregroundColor(.lightGrayColor) + .font(.system(size: 16.0)) } + .padding(8) .font(.system(size: self.keyboardFontSize)) } + .background(Color.keyboardToolbarColor) } private func placeholder() -> String { @@ -339,7 +351,7 @@ struct ComposeView: View { private func isPublishButtonDisabled() -> Bool { // Publish always disabled when there is not status text. - if self.text.isEmpty { + if self.textFieldViewModel.text.string.isEmpty { return true } @@ -357,7 +369,7 @@ struct ComposeView: View { } private func isInteractiveDismissDisabled() -> Bool { - if self.text.isEmpty == false { + if self.textFieldViewModel.text.string.isEmpty == false { return true } @@ -394,8 +406,8 @@ struct ComposeView: View { // Now we have to get from photos images as JPEG. for item in self.photosAttachment.filter({ $0.photoData == nil }) { - if var imageFileTransferable = try await item.photosPickerItem.loadTransferable(type: ImageFileTranseferable.self) { - item.photoData = imageFileTransferable.data + if let data = try await item.photosPickerItem.loadTransferable(type: Data.self) { + item.photoData = data } } @@ -487,7 +499,7 @@ struct ComposeView: View { private func createStatus() -> Pixelfed.Statuses.Components { return Pixelfed.Statuses.Components(inReplyToId: self.statusViewModel?.id, - text: self.text, + text: self.textFieldViewModel.text.string, spoilerText: self.isSensitive ? self.spoilerText : String.empty(), mediaIds: self.photosAttachment.getUploadedPhotoIds(), visibility: self.visibility, diff --git a/Vernissage/Views/ComposeView/TextFieldViewModel.swift b/Vernissage/Views/ComposeView/TextFieldViewModel.swift new file mode 100644 index 0000000..5dd3596 --- /dev/null +++ b/Vernissage/Views/ComposeView/TextFieldViewModel.swift @@ -0,0 +1,122 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import Foundation +import SwiftUI + +@MainActor +public class TextFieldViewModel: NSObject, ObservableObject { + + var textView: UITextView? + + var selectedRange: NSRange { + get { + guard let textView else { + return .init(location: 0, length: 0) + } + + return textView.selectedRange + } + set { + textView?.selectedRange = newValue + } + } + + var markedTextRange: UITextRange? { + guard let textView else { + return nil + } + return textView.markedTextRange + } + + @Published var text = NSMutableAttributedString(string: "") { + didSet { + let range = selectedRange + processText() + // checkEmbed() + textView?.attributedText = text + selectedRange = range + } + } + + private var urlLengthAdjustments: Int = 0 + private let maxLengthOfUrl = 23 + + public func append(content: String) { + let attrString = self.text + attrString.append(NSAttributedString(string: content)) + self.text = attrString + + selectedRange.location += content.utf16.count + } + + private func processText() { + guard markedTextRange == nil else { return } + + text.addAttributes([.foregroundColor: UIColor(Color.label), + .font: UIFont.systemFont(ofSize: TextView.bodyFontSize), + .backgroundColor: UIColor.clear, + .underlineColor: UIColor.clear], + range: NSMakeRange(0, text.string.utf16.count)) + + let hashtagPattern = "(#+[a-zA-Z0-9(_)]{1,})" + let mentionPattern = "(@+[a-zA-Z0-9(_).-]{1,})" + let urlPattern = "(?i)https?://(?:www\\.)?\\S+(?:/|\\b)" + + do { + let hashtagRegex = try NSRegularExpression(pattern: hashtagPattern, options: []) + let mentionRegex = try NSRegularExpression(pattern: mentionPattern, options: []) + let urlRegex = try NSRegularExpression(pattern: urlPattern, options: []) + + let range = NSMakeRange(0, text.string.utf16.count) + var ranges = hashtagRegex.matches(in: text.string, options: [], range: range).map { $0.range } + ranges.append(contentsOf: mentionRegex.matches(in: text.string, options: [], range: range).map { $0.range }) + + let urlRanges = urlRegex.matches(in: text.string, options: [], range: range).map { $0.range } + + // var foundSuggestionRange = false + for nsRange in ranges { + text.addAttributes([.foregroundColor: UIColor(.accentColor)], range: nsRange) + +// if selectedRange.location == (nsRange.location + nsRange.length), +// let range = Range(nsRange, in: text.string) { +// foundSuggestionRange = true +// currentSuggestionRange = nsRange +// loadAutoCompleteResults(query: String(text.string[range])) +// } + } + +// if !foundSuggestionRange || ranges.isEmpty { +// resetAutoCompletion() +// } + + var totalUrlLength = 0 + var numUrls = 0 + + for range in urlRanges { + if range.length > maxLengthOfUrl { + numUrls += 1 + totalUrlLength += range.length + } + + text.addAttributes([.foregroundColor: UIColor(.accentColor), + .underlineStyle: NSUnderlineStyle.single.rawValue, + .underlineColor: UIColor(.accentColor)], + range: NSRange(location: range.location, length: range.length)) + } + + urlLengthAdjustments = totalUrlLength - (maxLengthOfUrl * numUrls) + + text.enumerateAttributes(in: range) { attributes, range, _ in + if attributes[.link] != nil { + text.removeAttribute(.link, range: range) + } + } + } catch { } + + } +} + diff --git a/Vernissage/Widgets/TextView.swift b/Vernissage/Widgets/TextView.swift new file mode 100644 index 0000000..a645cca --- /dev/null +++ b/Vernissage/Widgets/TextView.swift @@ -0,0 +1,220 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import Foundation +import SwiftUI + +public struct TextView: View { + @Environment(\.layoutDirection) private var layoutDirection + + @Binding private var text: NSMutableAttributedString + @Binding private var isEmpty: Bool + + @State private var calculatedHeight: CGFloat = 44 + + private var getTextView: ((UITextView) -> Void)? + public static let bodyFontSize = 17.0 + + var placeholderView: AnyView? + var keyboard: UIKeyboardType = .default + + public init(_ text: Binding, + getTextView: ((UITextView) -> Void)? = nil) + { + _text = text + _isEmpty = Binding( + get: { text.wrappedValue.string.isEmpty }, + set: { _ in } + ) + + self.getTextView = getTextView + } + + public var body: some View { + Representable( + text: $text, + calculatedHeight: $calculatedHeight, + keyboard: keyboard, + getTextView: getTextView + ) + .frame( + minHeight: calculatedHeight, + maxHeight: calculatedHeight + ) + .background( + placeholderView? + .foregroundColor(Color(.placeholderText)) + .multilineTextAlignment(.leading) + .font(Font.body) + .padding(.horizontal, 0) + .padding(.vertical, 0) + .opacity(isEmpty ? 1 : 0), + alignment: .topLeading + ) + } +} + +final class UIKitTextView: UITextView { + override var keyCommands: [UIKeyCommand]? { + return (super.keyCommands ?? []) + [ + UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(escape(_:))), + ] + } + + @objc private func escape(_: Any) { + resignFirstResponder() + } +} + +extension TextView { + struct Representable: UIViewRepresentable { + + @Binding var text: NSMutableAttributedString + @Binding var calculatedHeight: CGFloat + + let keyboard: UIKeyboardType + var getTextView: ((UITextView) -> Void)? + + func makeUIView(context: Context) -> UIKitTextView { + context.coordinator.textView + } + + func updateUIView(_: UIKitTextView, context: Context) { + context.coordinator.update(representable: self) + if !context.coordinator.didBecomeFirstResponder { + context.coordinator.textView.becomeFirstResponder() + context.coordinator.didBecomeFirstResponder = true + } + } + + @discardableResult func makeCoordinator() -> Coordinator { + Coordinator( + text: $text, + calculatedHeight: $calculatedHeight, + getTextView: getTextView + ) + } + } +} + +extension TextView.Representable { + final class Coordinator: NSObject, UITextViewDelegate { + internal let textView: UIKitTextView + + private var originalText: NSMutableAttributedString = .init() + private var text: Binding + private var calculatedHeight: Binding + + var didBecomeFirstResponder = false + + var getTextView: ((UITextView) -> Void)? + + init(text: Binding, + calculatedHeight: Binding, + getTextView: ((UITextView) -> Void)?) { + + textView = UIKitTextView() + textView.backgroundColor = .clear + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textView.isScrollEnabled = false + textView.textContainer.lineFragmentPadding = 0 + textView.textContainerInset = .zero + + self.text = text + self.calculatedHeight = calculatedHeight + self.getTextView = getTextView + + super.init() + + textView.delegate = self + + textView.font = .systemFont(ofSize: TextView.bodyFontSize) + textView.adjustsFontForContentSizeCategory = true + textView.autocapitalizationType = .sentences + textView.autocorrectionType = .yes + textView.isEditable = true + textView.isSelectable = true + textView.dataDetectorTypes = [] + textView.allowsEditingTextAttributes = false + textView.returnKeyType = .default + textView.allowsEditingTextAttributes = true + + self.getTextView?(textView) + } + + func textViewDidBeginEditing(_: UITextView) { + originalText = text.wrappedValue + DispatchQueue.main.async { + self.recalculateHeight() + } + } + + func textViewDidChange(_ textView: UITextView) { + DispatchQueue.main.async { + self.text.wrappedValue = NSMutableAttributedString(attributedString: textView.attributedText) + self.recalculateHeight() + } + } + + func textView(_: UITextView, shouldChangeTextIn _: NSRange, replacementText _: String) -> Bool { + return true + } + } +} + +extension TextView.Representable.Coordinator { + func update(representable: TextView.Representable) { + textView.keyboardType = representable.keyboard + recalculateHeight() + textView.setNeedsDisplay() + } + + private func recalculateHeight() { + let newSize = textView.sizeThatFits(CGSize(width: textView.frame.width, height: .greatestFiniteMagnitude)) + guard calculatedHeight.wrappedValue != newSize.height else { return } + + DispatchQueue.main.async { // call in next render cycle. + self.calculatedHeight.wrappedValue = newSize.height + } + } +} + +public extension TextView { + /// Specify a placeholder text + /// - Parameter placeholder: The placeholder text + func placeholder(_ placeholder: String) -> TextView { + self.placeholder(placeholder) { $0 } + } + + /// Specify a placeholder with the specified configuration + /// + /// Example: + /// + /// TextView($text) + /// .placeholder("placeholder") { view in + /// view.foregroundColor(.red) + /// } + func placeholder(_ placeholder: String, _ configure: (Text) -> V) -> TextView { + var view = self + let text = Text(placeholder) + view.placeholderView = AnyView(configure(text)) + return view + } + + /// Specify a custom placeholder view + func placeholder(_ placeholder: V) -> TextView { + var view = self + view.placeholderView = AnyView(placeholder) + return view + } + + func setKeyboardType(_ keyboardType: UIKeyboardType) -> TextView { + var view = self + view.keyboard = keyboardType + return view + } +} +