ios: Animated images (GIF) support (#1636)
* ios: Animated images (GIF) support * Moved from String path to UIImage param * Aspect ratio * Image frame * gif image size * refactor * refactor * fix fullscreen scroll animation * rename UploadContent -> AnyImage * refactor, allow using gifs in profiles * rename back Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
6205b03943
commit
cd63f81292
@@ -8,6 +8,8 @@
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
import SwiftyGif
|
||||
import PhotosUI
|
||||
|
||||
enum ComposePreview {
|
||||
case noPreview
|
||||
@@ -169,6 +171,37 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
|
||||
return chatItemPreview
|
||||
}
|
||||
|
||||
enum UploadContent: Equatable {
|
||||
case simpleImage(image: UIImage)
|
||||
case animatedImage(image: UIImage)
|
||||
|
||||
var uiImage: UIImage {
|
||||
switch self {
|
||||
case let .simpleImage(image): return image
|
||||
case let .animatedImage(image): return image
|
||||
}
|
||||
}
|
||||
|
||||
static func loadFromURL(url: URL) -> UploadContent? {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
if let image = UIImage(data: data) {
|
||||
try image.setGifFromData(data, levelOfIntegrity: 1.0)
|
||||
logger.log("UploadContent: added animated image")
|
||||
return .animatedImage(image: image)
|
||||
} else { return nil }
|
||||
} catch {
|
||||
do {
|
||||
if let image = try UIImage(data: Data(contentsOf: url)) {
|
||||
logger.log("UploadContent: added simple image")
|
||||
return .simpleImage(image: image)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct ComposeView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var chat: Chat
|
||||
@@ -183,7 +216,7 @@ struct ComposeView: View {
|
||||
@State private var showChooseSource = false
|
||||
@State private var showImagePicker = false
|
||||
@State private var showTakePhoto = false
|
||||
@State var chosenImages: [UIImage] = []
|
||||
@State var chosenImages: [UploadContent] = []
|
||||
@State private var showFileImporter = false
|
||||
@State var chosenFile: URL? = nil
|
||||
|
||||
@@ -231,7 +264,7 @@ struct ComposeView: View {
|
||||
},
|
||||
finishVoiceMessageRecording: finishVoiceMessageRecording,
|
||||
allowVoiceMessagesToContact: allowVoiceMessagesToContact,
|
||||
onImageAdded: { image in chosenImages = [image] },
|
||||
onImagesAdded: { images in if !images.isEmpty { chosenImages = images }},
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
.padding(.trailing, 12)
|
||||
@@ -256,7 +289,15 @@ struct ComposeView: View {
|
||||
}
|
||||
if UIPasteboard.general.hasImages {
|
||||
Button("Paste image") {
|
||||
chosenImages = imageList(UIPasteboard.general.image)
|
||||
UIPasteboard.general.itemProviders.forEach { p in
|
||||
if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
|
||||
p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in
|
||||
if let url = url, let image = UploadContent.loadFromURL(url: url) {
|
||||
chosenImages.append(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Choose file") {
|
||||
@@ -283,7 +324,7 @@ struct ComposeView: View {
|
||||
Task {
|
||||
var imgs: [String] = []
|
||||
for image in images {
|
||||
if let img = resizeImageToStrSize(image, maxDataSize: 14000) {
|
||||
if let img = resizeImageToStrSize(image.uiImage, maxDataSize: 14000) {
|
||||
imgs.append(img)
|
||||
await MainActor.run {
|
||||
composeState = composeState.copy(preview: .imagePreviews(imagePreviews: imgs))
|
||||
@@ -483,12 +524,12 @@ struct ComposeView: View {
|
||||
case let .imagePreviews(imagePreviews: images):
|
||||
let last = min(chosenImages.count, images.count) - 1
|
||||
for i in 0..<last {
|
||||
if let savedFile = saveImage(chosenImages[i]) {
|
||||
if let savedFile = saveAnyImage(chosenImages[i]) {
|
||||
_ = await send(.image(text: "", image: images[i]), quoted: nil, file: savedFile)
|
||||
}
|
||||
_ = try? await Task.sleep(nanoseconds: 100_000000)
|
||||
}
|
||||
if let savedFile = saveImage(chosenImages[last]) {
|
||||
if let savedFile = saveAnyImage(chosenImages[last]) {
|
||||
sent = await send(.image(text: msgText, image: images[last]), quoted: quoted, file: savedFile, live: live)
|
||||
}
|
||||
if sent == nil {
|
||||
@@ -585,6 +626,13 @@ struct ComposeView: View {
|
||||
return .text(msgText)
|
||||
}
|
||||
}
|
||||
|
||||
func saveAnyImage(_ img: UploadContent) -> String? {
|
||||
switch img {
|
||||
case let .simpleImage(image): return saveImage(image)
|
||||
case let .animatedImage(image): return saveAnimImage(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startVoiceMessageRecording() async {
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftyGif
|
||||
import SimpleXChat
|
||||
import PhotosUI
|
||||
|
||||
struct NativeTextEditor: UIViewRepresentable {
|
||||
@Binding var text: String
|
||||
@@ -14,7 +17,7 @@ struct NativeTextEditor: UIViewRepresentable {
|
||||
let font: UIFont
|
||||
@FocusState.Binding var focused: Bool
|
||||
let alignment: TextAlignment
|
||||
let onImageAdded: (UIImage) -> Void
|
||||
let onImagesAdded: ([UploadContent]) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let field = CustomUITextField()
|
||||
@@ -23,10 +26,10 @@ struct NativeTextEditor: UIViewRepresentable {
|
||||
field.font = font
|
||||
field.textAlignment = alignment == .leading ? .left : .right
|
||||
field.autocapitalizationType = .sentences
|
||||
field.setOnTextChangedListener { newText, image in
|
||||
field.setOnTextChangedListener { newText, images in
|
||||
text = newText
|
||||
if let image = image {
|
||||
onImageAdded(image)
|
||||
if !images.isEmpty {
|
||||
onImagesAdded(images)
|
||||
}
|
||||
}
|
||||
field.setOnFocusChangedListener { focused = $0 }
|
||||
@@ -43,33 +46,85 @@ struct NativeTextEditor: UIViewRepresentable {
|
||||
}
|
||||
|
||||
private class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
var onTextChanged: (String, UIImage?) -> Void = { newText, image in }
|
||||
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
|
||||
var onFocusChanged: (Bool) -> Void = { focused in }
|
||||
|
||||
func setOnTextChangedListener(onTextChanged: @escaping (String, UIImage?) -> Void) {
|
||||
func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) {
|
||||
self.onTextChanged = onTextChanged
|
||||
}
|
||||
|
||||
func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) {
|
||||
self.onFocusChanged = onFocusChanged
|
||||
}
|
||||
|
||||
|
||||
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
|
||||
if !UIPasteboard.general.hasImages { return UIMenu(children: suggestedActions)}
|
||||
return UIMenu(children: suggestedActions.map { elem in
|
||||
if let elem = elem as? UIMenu {
|
||||
var actions = elem.children
|
||||
// Replacing Paste action since it allows to paste animated images too
|
||||
let pasteIndex = elem.children.firstIndex { elem in elem.debugDescription.contains("Action: paste:")}
|
||||
if let pasteIndex = pasteIndex {
|
||||
let paste = actions[pasteIndex]
|
||||
actions.remove(at: pasteIndex)
|
||||
let newPaste = UIAction(title: paste.title, image: paste.image) { action in
|
||||
var images: [UploadContent] = []
|
||||
var totalImages = 0
|
||||
var processed = 0
|
||||
UIPasteboard.general.itemProviders.forEach { p in
|
||||
if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
|
||||
totalImages += 1
|
||||
p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in
|
||||
processed += 1
|
||||
if let url = url, let image = UploadContent.loadFromURL(url: url) {
|
||||
images.append(image)
|
||||
DispatchQueue.main.sync {
|
||||
self.onTextChanged(textView.text, images)
|
||||
}
|
||||
}
|
||||
// No images were added, just paste a text then
|
||||
if processed == totalImages && images.isEmpty {
|
||||
textView.paste(UIPasteboard.general.string)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
actions.insert(newPaste, at: 0)
|
||||
}
|
||||
return UIMenu(title: elem.title, subtitle: elem.subtitle, image: elem.image, identifier: elem.identifier, options: elem.options, children: actions)
|
||||
} else {
|
||||
return elem
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
var image: UIImage? = nil
|
||||
var images: [UploadContent] = []
|
||||
var rangeDiff = 0
|
||||
let newAttributedText = NSMutableAttributedString(attributedString: textView.attributedText)
|
||||
textView.attributedText.enumerateAttribute(
|
||||
NSAttributedString.Key.attachment,
|
||||
in: NSRange(location: 0, length: textView.attributedText.length),
|
||||
options: [],
|
||||
using: { value, range, _ in
|
||||
if let attachment = (value as? NSTextAttachment)?.image {
|
||||
image = attachment
|
||||
let newText = NSMutableAttributedString(attributedString: textView.attributedText)
|
||||
newText.replaceCharacters(in: range, with: "")
|
||||
textView.attributedText = newText
|
||||
if let attachment = (value as? NSTextAttachment)?.fileWrapper?.regularFileContents {
|
||||
do {
|
||||
images.append(.animatedImage(image: try UIImage(gifData: attachment)))
|
||||
} catch {
|
||||
if let img = (value as? NSTextAttachment)?.image {
|
||||
images.append(.simpleImage(image: img))
|
||||
}
|
||||
}
|
||||
newAttributedText.replaceCharacters(in: NSMakeRange(range.location - rangeDiff, range.length), with: "")
|
||||
rangeDiff += range.length
|
||||
}
|
||||
}
|
||||
)
|
||||
onTextChanged(textView.text, image)
|
||||
if textView.attributedText != newAttributedText {
|
||||
textView.attributedText = newAttributedText
|
||||
}
|
||||
onTextChanged(textView.text, images)
|
||||
}
|
||||
|
||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
@@ -90,7 +145,7 @@ struct NativeTextEditor_Previews: PreviewProvider{
|
||||
font: UIFont.preferredFont(forTextStyle: .body),
|
||||
focused: $keyboardVisible,
|
||||
alignment: TextAlignment.leading,
|
||||
onImageAdded: { _ in }
|
||||
onImagesAdded: { _ in }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ struct SendMessageView: View {
|
||||
var startVoiceMessageRecording: (() -> Void)? = nil
|
||||
var finishVoiceMessageRecording: (() -> Void)? = nil
|
||||
var allowVoiceMessagesToContact: (() -> Void)? = nil
|
||||
var onImageAdded: (UIImage) -> Void
|
||||
var onImagesAdded: ([UploadContent]) -> Void
|
||||
@State private var holdingVMR = false
|
||||
@Namespace var namespace
|
||||
@FocusState.Binding var keyboardVisible: Bool
|
||||
@@ -66,7 +66,7 @@ struct SendMessageView: View {
|
||||
font: teUiFont,
|
||||
focused: $keyboardVisible,
|
||||
alignment: alignment,
|
||||
onImageAdded: onImageAdded
|
||||
onImagesAdded: onImagesAdded
|
||||
)
|
||||
.allowsTightening(false)
|
||||
.frame(height: teHeight)
|
||||
@@ -314,7 +314,7 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
SendMessageView(
|
||||
composeState: $composeStateNew,
|
||||
sendMessage: {},
|
||||
onImageAdded: { _ in },
|
||||
onImagesAdded: { _ in },
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
}
|
||||
@@ -324,7 +324,7 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
SendMessageView(
|
||||
composeState: $composeStateEditing,
|
||||
sendMessage: {},
|
||||
onImageAdded: { _ in },
|
||||
onImagesAdded: { _ in },
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user