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:
Stanislav Dmitrenko
2022-12-24 00:22:12 +03:00
committed by GitHub
parent 6205b03943
commit cd63f81292
13 changed files with 308 additions and 58 deletions

View File

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

View File

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

View File

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