diff --git a/Sources/SAPCAI/UI/AssistantView.swift b/Sources/SAPCAI/UI/AssistantView.swift index 492b4ee..4aa6ced 100644 --- a/Sources/SAPCAI/UI/AssistantView.swift +++ b/Sources/SAPCAI/UI/AssistantView.swift @@ -1,4 +1,5 @@ import Combine +import SDWebImage import SwiftUI /// SwiftUI main view. Use this when you want a full screen out-of-the box view that contains: @@ -83,6 +84,10 @@ public struct AssistantView: View { .onAppear { self.viewModel.load() } + .onDisappear { + SDImageCache.shared.clearMemory() + SDImageCache.shared.clearDisk(onCompletion: nil) + } } } diff --git a/Sources/SAPCAI/UI/Common/SwiftUI/AvatarView.swift b/Sources/SAPCAI/UI/Common/SwiftUI/AvatarView.swift index b4a0991..3d4597f 100644 --- a/Sources/SAPCAI/UI/Common/SwiftUI/AvatarView.swift +++ b/Sources/SAPCAI/UI/Common/SwiftUI/AvatarView.swift @@ -4,15 +4,13 @@ struct AvatarView: View { var imageUrl: String var body: some View { - if let url = URL(string: imageUrl) { - ImageViewWrapper(url: url) { $0 } - .scaledToFill() - .placeholder(Image(systemName: "person.crop.circle")) - .frame(width: 32, height: 32, alignment: .center) - .clipped() - } else { - Image(systemName: "icloud.slash") - } + ImageViewWrapper(url: URL(string: imageUrl), + placeholder: { Image(systemName: "person.crop.circle") }, + failure: { Text($0.localizedDescription) }, + content: { $0 }) + .scaledToFill() + .frame(width: 32, height: 32, alignment: .center) + .clipped() } } diff --git a/Sources/SAPCAI/UI/Common/SwiftUI/CarouselImageView.swift b/Sources/SAPCAI/UI/Common/SwiftUI/CarouselImageView.swift index 547eab5..43ddd42 100644 --- a/Sources/SAPCAI/UI/Common/SwiftUI/CarouselImageView.swift +++ b/Sources/SAPCAI/UI/Common/SwiftUI/CarouselImageView.swift @@ -12,8 +12,10 @@ struct CarouselImageView: View { var body: some View { Group { if let mediaItem = media, let sourceUrl = mediaItem.sourceUrl { - ImageViewWrapper(url: sourceUrl) { $0 } - .placeholder(mediaItem.placeholder) + 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 29df898..ba3e38c 100644 --- a/Sources/SAPCAI/UI/Common/SwiftUI/ImageUIView.swift +++ b/Sources/SAPCAI/UI/Common/SwiftUI/ImageUIView.swift @@ -52,7 +52,6 @@ struct ImageUIView: View { }) } .scaledToFill() - .sizeInto(box: boundingBox) } else { fallback } diff --git a/Sources/SAPCAI/UI/Common/SwiftUI/ImageView.swift b/Sources/SAPCAI/UI/Common/SwiftUI/ImageView.swift index f3f543d..088b852 100644 --- a/Sources/SAPCAI/UI/Common/SwiftUI/ImageView.swift +++ b/Sources/SAPCAI/UI/Common/SwiftUI/ImageView.swift @@ -9,8 +9,11 @@ struct ImageView: View { if imageUrl.absoluteString.range(of: "sap-icon") != nil { IconImageView(iconUrl: imageUrl.absoluteString, iconSize: CGSize(width: 50, height: 50)) } else { - ImageViewWrapper(url: imageUrl) { $0 } - .errorImage(Image(systemName: "photo")) + 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 index dfe848d..d78e520 100644 --- a/Sources/SAPCAI/UI/Common/UIKit/ImageViewWrapper.swift +++ b/Sources/SAPCAI/UI/Common/UIKit/ImageViewWrapper.swift @@ -2,17 +2,14 @@ import SDWebImage import SwiftUI class ImageManager: ObservableObject { - enum ImageState: Equatable { + enum ImageState { case initial - case error + case error(_ error: Error) case completed(image: UIImage) } @Published var state: ImageState = .initial var contentMode: UIView.ContentMode = .scaleAspectFit - var box: BoundingBox? - var placeholder: Image? - var errorImage: Image? var imgSize: CGSize { switch self.state { @@ -24,25 +21,27 @@ class ImageManager: ObservableObject { } } -struct ImageViewWrapper: View where Content: View { +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.init(url: url, content: content, manager: ImageManager()) - } - - private init(url: URL?, - @ViewBuilder content: @escaping (_ imgView: ImageViewRepresentable) -> Content, - manager: ImageManager) { 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() @@ -52,19 +51,11 @@ struct ImageViewWrapper: View where Content: View { ZStack { switch imageManager.state { case .initial: - if let placeholder = imageManager.placeholder { - placeholder - } else { - EmptyView() - } + placeholder() case .completed(image: _): content(imageView) - case .error: - if let errorImage = imageManager.errorImage { - errorImage - } else { - EmptyView() - } + case .error(let error): + failure(error) } } } @@ -78,85 +69,57 @@ struct ImageViewWrapper: View where Content: View { self.imageManager.contentMode = .scaleAspectFill return self } - - func sizeInto(box: BoundingBox) -> Self { - self.imageManager.box = box - return self - } - - func placeholder(_ placeholder: Image) -> Self { - self.imageManager.placeholder = placeholder - return self - } - - func errorImage(_ errorImage: Image) -> Self { - self.imageManager.errorImage = errorImage - return self - } } -struct ImageViewRepresentable: UIViewRepresentable { - var url: URL? - var imageManager: ImageManager - let wrapper = UIImageViewWrapper() - - func makeUIView(context: Self.Context) -> UIImageViewWrapper { - self.wrapper +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) } +} - func updateUIView(_ uiView: UIImageViewWrapper, context: UIViewRepresentableContext) { - uiView.imageView.contentMode = self.imageManager.contentMode - /// retry strategy missed - if uiView.imageView.image == nil, self.imageManager.state != .error { - self.loadImage() - } - } - - func loadImage() { - self.wrapper.imageView.sd_setImage(with: self.url, - placeholderImage: nil) { image, error, _, _ in - if error == nil, let image = image { - imageManager.state = .completed(image: image) - } else { - imageManager.state = .error - } - } +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) } } -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() +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) } } -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 +#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) }) + } } - 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 } -} +#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 + } +}