Media loading flow changes (#483)

* Use an imageProvider directly from the view in the home screen

* Add support for media request coalescing

* Rename MediaProxy to MediaLoader

* Add new image loading mechanism to the room details screen avatar.

* Use the `SettingsScreen` prefix for all settings screen related components

* Add new image loading mechanism to the room header

* Add new image loading mechanism to the room member details screen

* Introduce a LoadableImage SwiftUI view that will automatically handle image loading

* Adopt the new LoadableImage where possible

* Fix LoadableImage not using/storing loaded images properly

* Simplify media loader enqueueing

* Made LodableImage load content after mediaSource updates. Adopt it on the home and settings screens

* Introduce a LoadableAvatarImage component and reuse it throughout the app

* Small logging tweaks, made some LoadableImage properties private

* Fix redacted skeletons avatar background color

* Fix placeholder avatars changing when backgrounding the app

* PR comments.

- Trim the @ sign off of mxid placeholders.
- Only expose AvatarSize on the avatar image, use CGSize elsewhere.

Co-authored-by: Doug <douglase@element.io>
This commit is contained in:
Stefan Ceriu
2023-01-25 19:45:01 +02:00
committed by GitHub
parent 3a74f13097
commit bd4c0b72cb
58 changed files with 875 additions and 830 deletions
@@ -0,0 +1,50 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct LoadableAvatarImage: View {
private let url: URL?
private let name: String?
private let contentID: String?
private let avatarSize: AvatarSize
private let imageProvider: ImageProviderProtocol?
@ScaledMetric private var frameSize: CGFloat
init(url: URL?, name: String?, contentID: String?, avatarSize: AvatarSize, imageProvider: ImageProviderProtocol?) {
self.url = url
self.name = name
self.contentID = contentID
self.avatarSize = avatarSize
self.imageProvider = imageProvider
_frameSize = ScaledMetric(wrappedValue: avatarSize.value)
}
var body: some View {
LoadableImage(url: url,
size: avatarSize.scaledSize,
imageProvider: imageProvider) { image in
image
.scaledToFill()
} placeholder: {
PlaceholderAvatarImage(name: name, contentID: contentID)
}
.frame(width: frameSize, height: frameSize)
.clipShape(Circle())
}
}
@@ -0,0 +1,102 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct LoadableImage<TransformerView: View, PlaceholderView: View>: View {
private let mediaSource: MediaSourceProxy?
private let blurhash: String?
private let size: CGSize?
private let imageProvider: ImageProviderProtocol?
private var transformer: (Image) -> TransformerView
private let placeholder: () -> PlaceholderView
@State private var cachedImage: UIImage?
/// A SwiftUI view that automatically fetches images
/// It will try fetching the image from in-memory cache and if that's not available
/// it will fire a task to load it through the image provider
/// - Parameters:
/// - mediaSource: the source of the image
/// - blurhash: an optional blurhash
/// - transformer: entry point for configuring the resulting image view
/// - placeholder: a view to show while the image or blurhash are not available
init(mediaSource: MediaSourceProxy?,
blurhash: String? = nil,
size: CGSize? = nil,
imageProvider: ImageProviderProtocol?,
transformer: @escaping (Image) -> TransformerView = { $0 },
placeholder: @escaping () -> PlaceholderView) {
self.mediaSource = mediaSource
self.blurhash = blurhash
self.size = size
self.imageProvider = imageProvider
self.transformer = transformer
self.placeholder = placeholder
}
init(url: URL?,
blurhash: String? = nil,
size: CGSize? = nil,
imageProvider: ImageProviderProtocol?,
transformer: @escaping (Image) -> TransformerView = { $0 },
placeholder: @escaping () -> PlaceholderView) {
let mediaSource = url.map(MediaSourceProxy.init)
self.init(mediaSource: mediaSource,
blurhash: blurhash,
size: size,
imageProvider: imageProvider,
transformer: transformer,
placeholder: placeholder)
}
var body: some View {
let _ = Task {
// Future improvement: Does guarding against a nil image prevent the image being updated when the URL changes?
guard image == nil, let mediaSource else { return }
if case let .success(image) = await imageProvider?.loadImageFromSource(mediaSource, size: size) {
self.cachedImage = image
}
}
ZStack {
if let image {
transformer(
Image(uiImage: image)
.resizable()
)
} else if let blurhash,
// Build a small blurhash image so that it's fast
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
transformer(
Image(uiImage: image)
.resizable()
)
} else {
placeholder()
}
}
.animation(.elementDefault, value: image)
}
private var image: UIImage? {
cachedImage ?? imageProvider?.imageFromSource(mediaSource, size: size)
}
}
@@ -0,0 +1,76 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct PlaceholderAvatarImage: View {
@Environment(\.redactionReasons) var redactionReasons
private let textForImage: String
private let contentID: String?
var body: some View {
ZStack {
bgColor
// This text's frame doesn't look right when redacted
if redactionReasons != .placeholder {
Text(textForImage)
.padding(4)
.foregroundColor(.white)
.font(.system(size: 200).weight(.semibold))
.minimumScaleFactor(0.001)
}
}
.aspectRatio(1, contentMode: .fill)
}
init(name: String?, contentID: String?) {
let baseName = name ?? contentID?.trimmingCharacters(in: .punctuationCharacters)
textForImage = baseName?.first?.uppercased() ?? ""
self.contentID = contentID
}
private var bgColor: Color {
if redactionReasons == .placeholder {
return .element.systemGray4
}
guard let contentID else {
return .element.accent
}
return .element.avatarBackground(for: contentID)
}
}
struct PlaceholderAvatarImage_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 75) {
PlaceholderAvatarImage(name: "Xavier", contentID: "@userid1:matrix.org")
.clipShape(Circle())
.frame(width: 150, height: 100)
PlaceholderAvatarImage(name: "@*~AmazingName~*@", contentID: "@userid2:matrix.org")
.clipShape(Circle())
.frame(width: 150, height: 100)
PlaceholderAvatarImage(name: nil, contentID: "@userid3:matrix.org")
.clipShape(Circle())
.frame(width: 150, height: 100)
}
}
}