mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 00:49:44 +00:00
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:
@@ -36,6 +36,10 @@ class ViewModelContext<ViewState: BindableState, ViewAction>: ObservableObject {
|
||||
|
||||
/// Get-able/Observable `Published` property for the `ViewState`
|
||||
@Published fileprivate(set) var viewState: ViewState
|
||||
|
||||
/// An optional image loading service so that views can manage themselves
|
||||
/// Intentionally non-generic so that it doesn't grow uncontrollably
|
||||
let imageProvider: ImageProviderProtocol?
|
||||
|
||||
/// Set-able/Bindable access to the bindable state.
|
||||
subscript<T>(dynamicMember keyPath: WritableKeyPath<ViewState.BindStateType, T>) -> T {
|
||||
@@ -43,9 +47,10 @@ class ViewModelContext<ViewState: BindableState, ViewAction>: ObservableObject {
|
||||
set { viewState.bindings[keyPath: keyPath] = newValue }
|
||||
}
|
||||
|
||||
init(initialViewState: ViewState) {
|
||||
init(initialViewState: ViewState, imageProvider: ImageProviderProtocol?) {
|
||||
self.viewActions = PassthroughSubject()
|
||||
self.viewState = initialViewState
|
||||
self.imageProvider = imageProvider
|
||||
}
|
||||
|
||||
/// Send a `ViewAction` to the `ViewModel` for processing.
|
||||
@@ -78,8 +83,8 @@ class StateStoreViewModel<State: BindableState, ViewAction> {
|
||||
set { context.viewState = newValue }
|
||||
}
|
||||
|
||||
init(initialViewState: State) {
|
||||
context = Context(initialViewState: initialViewState)
|
||||
init(initialViewState: State, imageProvider: ImageProviderProtocol? = nil) {
|
||||
context = Context(initialViewState: initialViewState, imageProvider: imageProvider)
|
||||
context.viewActions
|
||||
.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user