diff --git a/CAITestApp/CAITestApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CAITestApp/CAITestApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e701b63..bee2c04 100644 --- a/CAITestApp/CAITestApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CAITestApp/CAITestApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -46,6 +46,15 @@ "version": "1.2.0" } }, + { + "package": "SDWebImage", + "repositoryURL": "https://github.com/SDWebImage/SDWebImage", + "state": { + "branch": null, + "revision": "76dd4b49110b8624317fc128e7fa0d8a252018bc", + "version": "5.11.1" + } + }, { "package": "SnapshotTesting", "repositoryURL": "https://github.com/pointfreeco/swift-snapshot-testing", @@ -64,15 +73,6 @@ "version": "0.4.1" } }, - { - "package": "URLImage", - "repositoryURL": "https://github.com/dmytro-anokhin/url-image", - "state": { - "branch": null, - "revision": "ccab89ad1cedb04f25dd4df1776dd8c8583b914a", - "version": "2.2.5" - } - }, { "package": "Zip", "repositoryURL": "https://github.com/marmelroy/Zip.git", diff --git a/CAITestApp/CAITestApp/MockData.swift b/CAITestApp/CAITestApp/MockData.swift index c89c3a9..64e06d4 100644 --- a/CAITestApp/CAITestApp/MockData.swift +++ b/CAITestApp/CAITestApp/MockData.swift @@ -22,7 +22,7 @@ struct MockData { // text message (user) arr.append(CAIResponseMessageData(text: "Can you show me a really, really, really long text message to verify that text wraps and looks pretty. And then some next with quick reply buttons!", false)) - + // text message (bot) arr.append(CAIResponseMessageData(text: "Sure, no problem at all. Here you go! In the next message I will send you a text with quick reply buttons. What would you like to see next?", true)) @@ -40,6 +40,12 @@ struct MockData { // image (bot) arr.append(CAIResponseMessageData(imageName: "https://hips.hearstapps.com/hmg-prod.s3.amazonaws.com/images/2019-mustang-shelby-gt350-101-1528733363.jpg?crop=0.817xw:1.00xh;0.149xw,0&resize=640:*")) + // text message (user) + arr.append(CAIResponseMessageData(text: "Please show me a gif", false)) + + // image (bot) + arr.append(CAIResponseMessageData(imageName: "http://assets.sbnation.com/assets/2512203/dogflops.gif")) + // text message (user) arr.append(CAIResponseMessageData(text: "I want to watch a video", false)) diff --git a/Package.resolved b/Package.resolved index 1746731..66f910a 100644 --- a/Package.resolved +++ b/Package.resolved @@ -47,21 +47,21 @@ } }, { - "package": "TinyNetworking", - "repositoryURL": "https://github.com/objcio/tiny-networking", + "package": "SDWebImage", + "repositoryURL": "https://github.com/SDWebImage/SDWebImage", "state": { "branch": null, - "revision": "a9af2917018e513c77061139fe3ce4e4d67cf259", - "version": "0.4.1" + "revision": "76dd4b49110b8624317fc128e7fa0d8a252018bc", + "version": "5.11.1" } }, { - "package": "URLImage", - "repositoryURL": "https://github.com/dmytro-anokhin/url-image", + "package": "TinyNetworking", + "repositoryURL": "https://github.com/objcio/tiny-networking", "state": { "branch": null, - "revision": "ccab89ad1cedb04f25dd4df1776dd8c8583b914a", - "version": "2.2.5" + "revision": "a9af2917018e513c77061139fe3ce4e4d67cf259", + "version": "0.4.1" } }, { diff --git a/Package.swift b/Package.swift index 8c9f0dc..5f218c9 100644 --- a/Package.swift +++ b/Package.swift @@ -18,7 +18,7 @@ let package = Package( ) ], dependencies: [ - .package(name: "URLImage", url: "https://github.com/dmytro-anokhin/url-image", .upToNextMajor(from: "2.0.0")), + .package(name: "SDWebImage", url: "https://github.com/SDWebImage/SDWebImage", .upToNextMinor(from: "5.11.0")), .package(name: "Down", url: "https://github.com/johnxnguyen/Down", .upToNextMinor(from: "0.11.0")), .package(name: "cloud-sdk-ios", url: "https://github.com/SAP/cloud-sdk-ios", .exact("5.1.3-xcfrwk")) ], @@ -35,7 +35,7 @@ let package = Package( .target( name: "SAPCAI", dependencies: [ - "URLImage", + "SDWebImage", "Down" ], resources: [ diff --git a/README.md b/README.md index 37547d2..817952f 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ curl -X POST "/connect/v1/channels" \ - [SAPCommon](https://help.sap.com/doc/978e4f6c968c4cc5a30f9d324aa4b1d7/Latest/en-US/Documents/Frameworks/SAPCommon/index.html) for Logging - [SAPFoundation](https://help.sap.com/doc/978e4f6c968c4cc5a30f9d324aa4b1d7/Latest/en-US/Documents/Frameworks/SAPFoundation/index.html) for Network Connectivity and Authentication -- [URLImage](https://github.com/dmytro-anokhin/url-image) for asynchronous image loading in SwiftUI +- [SDWebImage](https://github.com/SDWebImage/SDWebImage) for asynchronous image loading and gif animation in SwiftUI - [Down](https://github.com/johnxnguyen/Down) for Markdown / CommonMark rendering in Swift ## Installation @@ -146,6 +146,9 @@ AssistantView() .onDisappear { viewModel.cancelSubscriptions() dataPublisher.resetConversation() + //SAPCAI uses `SDWebImage` and its image caching capabilities but it is the app developers responsibility to clear the cache if that is desired + SDImageCache.shared.clearMemory() + SDImageCache.shared.clearDisk(onCompletion: nil) }) ``` @@ -196,7 +199,6 @@ You can also provide an alternative color palette or provide a custom theme. ### Image related - Not being able to save, copy, or share an image as part of a bot response. Currently, there is no gesture handler attached to an image view which could allow opening a contextual menu offering such features (similar to iMessage or WhatsApp). -- Animated images (GIF), as part of a bot response, will be viewed as a static image (dependency to https://github.com/dmytro-anokhin/url-image/issues/43) ## How to obtain support diff --git a/Sources/SAPCAI/UI/Common/SwiftUI/AvatarView.swift b/Sources/SAPCAI/UI/Common/SwiftUI/AvatarView.swift index 0cfe6a8..f0815fd 100644 --- a/Sources/SAPCAI/UI/Common/SwiftUI/AvatarView.swift +++ b/Sources/SAPCAI/UI/Common/SwiftUI/AvatarView.swift @@ -1,24 +1,16 @@ import SwiftUI -import URLImage struct AvatarView: View { var imageUrl: String var body: some View { - URLImage(url: URL(string: imageUrl)!, - inProgress: { _ -> Image in - Image(systemName: "person.crop.circle") - }, - failure: { error, _ in - Text(error.localizedDescription) - }, - content: { image in - image - .resizable() // Make image resizable - .aspectRatio(contentMode: .fill) // Fill the frame - .clipped() // Clip overlaping parts - }) + ImageViewWrapper(url: URL(string: imageUrl), + placeholder: { Image(systemName: "person.crop.circle") }, + failure: { _ in Image(systemName: "person.crop.circle") }, + content: { $0 }) + .scaledToFill() .frame(width: 32, height: 32, alignment: .center) + .clipped() } } diff --git a/Sources/SAPCAI/UI/Common/SwiftUI/CarouselDetailPage.swift b/Sources/SAPCAI/UI/Common/SwiftUI/CarouselDetailPage.swift index 39818e1..3ad7261 100644 --- a/Sources/SAPCAI/UI/Common/SwiftUI/CarouselDetailPage.swift +++ b/Sources/SAPCAI/UI/Common/SwiftUI/CarouselDetailPage.swift @@ -1,5 +1,4 @@ import SwiftUI -import URLImage struct CarouselDetailPage: View { @EnvironmentObject private var themeManager: ThemeManager @@ -40,12 +39,10 @@ struct CarouselDetailPage: View { @ViewBuilder var carouselHeaderImage: some View { if let imgURL = carouselItem?.itemPicture?.sourceUrl { - URLImage(url: imgURL) { image in + ImageViewWrapper(url: imgURL) { image in image - .resizable() .scaledToFill() .frame(width: screenWidth, height: 180.0 / 375 * screenWidth) - .aspectRatio(contentMode: .fill) .clipped() } } diff --git a/Sources/SAPCAI/UI/Common/SwiftUI/CarouselImageView.swift b/Sources/SAPCAI/UI/Common/SwiftUI/CarouselImageView.swift index cda076b..2fc56db 100644 --- a/Sources/SAPCAI/UI/Common/SwiftUI/CarouselImageView.swift +++ b/Sources/SAPCAI/UI/Common/SwiftUI/CarouselImageView.swift @@ -1,5 +1,4 @@ import SwiftUI -import URLImage struct CarouselImageView: View { var media: MediaItem? @@ -21,18 +20,11 @@ struct CarouselImageView: View { } .frame(width: self.itemWidth, alignment: .leading) } else { - URLImage(url: sourceUrl, - inProgress: { _ -> Image in - mediaItem.placeholder - }, - failure: { error, _ in - Text(error.localizedDescription) - }, - content: { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - }) + ImageViewWrapper(url: sourceUrl, + placeholder: { mediaItem.placeholder }, + failure: { Text($0.localizedDescription) }, + content: { $0 }) + .scaledToFill() .frame(width: self.itemWidth, height: self.vSizeClass == .regular ? 180 : 80) .clipped() } diff --git a/Sources/SAPCAI/UI/Common/SwiftUI/ImageUIView.swift b/Sources/SAPCAI/UI/Common/SwiftUI/ImageUIView.swift index 7411c91..ba3e38c 100644 --- a/Sources/SAPCAI/UI/Common/SwiftUI/ImageUIView.swift +++ b/Sources/SAPCAI/UI/Common/SwiftUI/ImageUIView.swift @@ -1,5 +1,4 @@ import SwiftUI -import URLImage /// Renders an image from a MediaItem data model /// @@ -34,28 +33,25 @@ struct ImageUIView: View { self.fallback = fallback } + var boundingBox: BoundingBox { + BoundingBox(minWidth: 44, + minHeight: 44, + maxWidth: self.hSizeClass == .regular ? 480 : self.geometry.size.width * 0.75, + maxHeight: self.vSizeClass == .regular ? 400 : 240) + } + // :nodoc: var body: some View { Group { if let sourceUrl = media?.sourceUrl { - URLImage(url: sourceUrl) { image, info in - SizeConverter( - CGSize(width: CGFloat(info.cgImage.width), height: CGFloat(info.cgImage.height)), - BoundingBox(minWidth: 44, - minHeight: 44, - maxWidth: self.hSizeClass == .regular ? 480 : self.geometry.size.width * 0.75, - maxHeight: self.vSizeClass == .regular ? 400 : 240), - content: { targetSize in - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: targetSize.width, height: targetSize.height) - } - ) - .preference(key: ImageSizeInfoPrefKey.self, - value: CGSize(width: info.cgImage.width, - height: info.cgImage.height)) + ImageViewWrapper(url: sourceUrl) { imgView in + let size = imgView.imageManager.imgSize + SizeConverter(size, boundingBox, content: { targetSize in + imgView.frame(width: targetSize.width, height: targetSize.height) + .preference(key: ImageSizeInfoPrefKey.self, value: targetSize) + }) } + .scaledToFill() } else { fallback } diff --git a/Sources/SAPCAI/UI/Common/SwiftUI/ImageView.swift b/Sources/SAPCAI/UI/Common/SwiftUI/ImageView.swift index 177bf0d..626009c 100644 --- a/Sources/SAPCAI/UI/Common/SwiftUI/ImageView.swift +++ b/Sources/SAPCAI/UI/Common/SwiftUI/ImageView.swift @@ -1,5 +1,4 @@ import SwiftUI -import URLImage /// Renders an image from a MediaItem data model struct ImageView: View { @@ -10,16 +9,13 @@ struct ImageView: View { if imageUrl.isSAPIcon { IconImageView(iconUrl: imageUrl.absoluteString, iconSize: CGSize(width: 50, height: 50)) } else { - URLImage(url: imageUrl, - failure: { _, _ in - Image(systemName: "photo") - }, - content: { image in - image - .resizable() - .aspectRatio(contentMode: .fit) - .cornerRadius(8) - }) + ImageViewWrapper(url: imageUrl, + failure: { _ in + Image(systemName: "photo") + }, + content: { $0 }) + .scaledToFit() + .cornerRadius(8) } } } diff --git a/Sources/SAPCAI/UI/Common/UIKit/ImageViewRepresentable.swift b/Sources/SAPCAI/UI/Common/UIKit/ImageViewRepresentable.swift new file mode 100644 index 0000000..3fb2dac --- /dev/null +++ b/Sources/SAPCAI/UI/Common/UIKit/ImageViewRepresentable.swift @@ -0,0 +1,55 @@ +import SDWebImage +import SwiftUI + +struct ImageViewRepresentable: UIViewRepresentable { + var url: URL? + var imageManager: ImageManager + let wrapper = UIImageViewWrapper() + + func makeUIView(context: Self.Context) -> UIImageViewWrapper { + self.wrapper + } + + func updateUIView(_ uiView: UIImageViewWrapper, context: UIViewRepresentableContext) { + uiView.imageView.contentMode = self.imageManager.contentMode + if case ImageManager.ImageState.error = self.imageManager.state, uiView.imageView.image == nil { + self.loadImage() + } + } + + func loadImage() { + self.wrapper.imageView.sd_setImage(with: self.url, + placeholderImage: nil) { image, error, _, _ in + if let error = error { + imageManager.state = .error(error) + } else if let image = image { + imageManager.state = .completed(image: image) + } else { + imageManager.state = .error(NSError(domain: "cai.com", code: 1, userInfo: nil)) + } + } + } +} + +class UIImageViewWrapper: UIView { + lazy var imageView: UIImageView = { + let imageView = UIImageView() + imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + imageView.setContentHuggingPriority(.defaultLow, for: .vertical) + imageView.setContentHuggingPriority(.defaultLow, for: .horizontal) + return imageView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + addSubview(self.imageView) + self.imageView.bindFrameToSuperviewBounds() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + addSubview(self.imageView) + self.imageView.bindFrameToSuperviewBounds() + } +} diff --git a/Sources/SAPCAI/UI/Common/UIKit/ImageViewWrapper.swift b/Sources/SAPCAI/UI/Common/UIKit/ImageViewWrapper.swift new file mode 100644 index 0000000..d78e520 --- /dev/null +++ b/Sources/SAPCAI/UI/Common/UIKit/ImageViewWrapper.swift @@ -0,0 +1,125 @@ +import SDWebImage +import SwiftUI + +class ImageManager: ObservableObject { + enum ImageState { + case initial + case error(_ error: Error) + case completed(image: UIImage) + } + + @Published var state: ImageState = .initial + var contentMode: UIView.ContentMode = .scaleAspectFit + + var imgSize: CGSize { + switch self.state { + case .completed(image: let image): + return image.size + case .initial, .error: + return .zero + } + } +} + +struct ImageViewWrapper: View { + @ObservedObject var imageManager: ImageManager + + let url: URL? + let imageView: ImageViewRepresentable + let content: (_ imgView: ImageViewRepresentable) -> Content + + private let failure: (_ error: Error) -> Failure + private let placeholder: () -> Placeholder + + init(url: URL?, + @ViewBuilder placeholder: @escaping () -> Placeholder, + @ViewBuilder failure: @escaping (_ error: Error) -> Failure, + @ViewBuilder content: @escaping (_ imgView: ImageViewRepresentable) -> Content) + { + self.url = url + self.placeholder = placeholder + self.failure = failure + self.content = content + + let manager = ImageManager() + self.imageManager = manager + self.imageView = ImageViewRepresentable(url: url, imageManager: manager) + self.imageView.loadImage() + } + + var body: some View { + ZStack { + switch imageManager.state { + case .initial: + placeholder() + case .completed(image: _): + content(imageView) + case .error(let error): + failure(error) + } + } + } + + func scaledToFit() -> Self { + self.imageManager.contentMode = .scaleAspectFit + return self + } + + func scaledToFill() -> Self { + self.imageManager.contentMode = .scaleAspectFill + return self + } +} + +extension ImageViewWrapper where Placeholder == EmptyView, Failure == EmptyView { + init(url: URL?, + @ViewBuilder content: @escaping (_ imgView: ImageViewRepresentable) -> Content) + { + self.init(url: url, + placeholder: { EmptyView() }, + failure: { _ in EmptyView() }, + content: content) + } +} + +extension ImageViewWrapper where Failure == EmptyView { + init(url: URL?, + @ViewBuilder placeholder: @escaping () -> Placeholder, + @ViewBuilder content: @escaping (_ imgView: ImageViewRepresentable) -> Content) + { + self.init(url: url, + placeholder: placeholder, + failure: { _ in EmptyView() }, + content: content) + } +} + +extension ImageViewWrapper where Placeholder == EmptyView { + init(url: URL?, + @ViewBuilder failure: @escaping (_ error: Error) -> Failure, + @ViewBuilder content: @escaping (_ imgView: ImageViewRepresentable) -> Content) + { + self.init(url: url, + placeholder: { EmptyView() }, + failure: failure, + content: content) + } +} + +#if DEBUG + struct ImageViewWrapper_Previews: PreviewProvider { + static var previews: some View { + let url = URL(string: "https://hips.hearstapps.com/hmg-prod.s3.amazonaws.com/images/2019-mustang-shelby-gt350-101-1528733363.jpg?crop=0.817xw:1.00xh;0.149xw,0&resize=640:*") + let gifURL = URL(string: "http://assets.sbnation.com/assets/2512203/dogflops.gif") + VStack { + ImageViewWrapper(url: url, content: { $0.frame(width: 100, height: 100) }) + + ImageViewWrapper(url: gifURL, content: { $0.frame(width: 300, height: 300) }) + + ImageViewWrapper(url: nil, + failure: { _ in Text("load image failed") }, + content: { $0.frame(width: 300, height: 300) }) + } + } + } +#endif diff --git a/Sources/SAPCAI/UI/Common/UIKit/UIViewExtension.swift b/Sources/SAPCAI/UI/Common/UIKit/UIViewExtension.swift new file mode 100644 index 0000000..d625c6a --- /dev/null +++ b/Sources/SAPCAI/UI/Common/UIKit/UIViewExtension.swift @@ -0,0 +1,15 @@ +import UIKit + +extension UIView { + func bindFrameToSuperviewBounds() { + guard let superview = self.superview else { + print("Error! `superview` was nil – call `addSubview(view: UIView)` before calling `bindFrameToSuperviewBounds()` to fix this.") + return + } + self.translatesAutoresizingMaskIntoConstraints = false + self.topAnchor.constraint(equalTo: superview.topAnchor, constant: 0).isActive = true + self.bottomAnchor.constraint(equalTo: superview.bottomAnchor, constant: 0).isActive = true + self.leadingAnchor.constraint(equalTo: superview.leadingAnchor, constant: 0).isActive = true + self.trailingAnchor.constraint(equalTo: superview.trailingAnchor, constant: 0).isActive = true + } +}