Add UITextView with color hashtags.
This commit is contained in:
parent
ba53dd9b7d
commit
a5ad0a3caa
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 { }
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue