Add UITextView with color hashtags.

This commit is contained in:
Marcin Czachursk 2023-02-27 14:03:22 +01:00
parent ba53dd9b7d
commit a5ad0a3caa
6 changed files with 555 additions and 154 deletions

View File

@ -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 = "<group>"; };
F87AEB932986C51B00434FB6 /* AppConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = "<group>"; };
F87AEB962986D16D00434FB6 /* AuthorisationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorisationError.swift; sourceTree = "<group>"; };
F8864CE829ACAF820020C534 /* TextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextView.swift; sourceTree = "<group>"; };
F8864CEA29ACBAA80020C534 /* TextFieldViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldViewModel.swift; sourceTree = "<group>"; };
F886F256297859E300879356 /* CacheImageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheImageService.swift; sourceTree = "<group>"; };
F88ABD9129686F1C004EF61E /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = "<group>"; };
F88ABD9329687CA4004EF61E /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
@ -460,6 +464,7 @@
F876418A298AC1B80057D362 /* NoDataView.swift */,
F86FB554298BF83F000131F0 /* FavouriteTouch.swift */,
F8FA991F299FDDC3007AB130 /* TextInputField.swift */,
F8864CE829ACAF820020C534 /* TextView.swift */,
);
path = Widgets;
sourceTree = "<group>";
@ -687,6 +692,7 @@
children = (
F878842029A494E3003CFAD2 /* Subviews */,
F88ABD9329687CA4004EF61E /* ComposeView.swift */,
F8864CEA29ACBAA80020C534 /* TextFieldViewModel.swift */,
);
path = ComposeView;
sourceTree = "<group>";
@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@ -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<NSMutableAttributedString>,
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<NSMutableAttributedString>
private var calculatedHeight: Binding<CGFloat>
var didBecomeFirstResponder = false
var getTextView: ((UITextView) -> Void)?
init(text: Binding<NSMutableAttributedString>,
calculatedHeight: Binding<CGFloat>,
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<V: View>(_ 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<V: View>(_ 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
}
}