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
@@ -49,7 +49,7 @@ enum RoomScreenViewAction {
struct RoomScreenViewState: BindableState {
var roomId: String
var roomTitle = ""
var roomAvatar: UIImage?
var roomAvatarURL: URL?
var items: [RoomTimelineViewProvider] = []
var canBackPaginate = true
var isBackPaginating = false
@@ -28,7 +28,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
private let timelineController: RoomTimelineControllerProtocol
private let timelineViewFactory: RoomTimelineViewFactoryProtocol
private let mediaProvider: MediaProviderProtocol
// MARK: - Setup
@@ -39,12 +38,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
roomAvatarUrl: URL? = nil) {
self.timelineController = timelineController
self.timelineViewFactory = timelineViewFactory
self.mediaProvider = mediaProvider
super.init(initialViewState: RoomScreenViewState(roomId: timelineController.roomID,
roomTitle: roomName ?? "Unknown room 💥",
roomAvatar: nil,
bindings: .init(composerText: "", composerFocused: false)))
roomAvatarURL: roomAvatarUrl,
bindings: .init(composerText: "", composerFocused: false)),
imageProvider: mediaProvider)
timelineController.callbacks
.receive(on: DispatchQueue.main)
@@ -76,15 +75,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.contextMenuBuilder = buildContextMenuForItemId(_:)
buildTimelineViews()
if let roomAvatarUrl {
Task {
if case let .success(avatar) = await mediaProvider.loadImageFromURL(roomAvatarUrl,
avatarSize: .room(on: .timeline)) {
state.roomAvatar = avatar
}
}
}
}
// MARK: - Public
@@ -38,24 +38,12 @@ struct RoomHeaderView: View {
}
@ViewBuilder private var roomAvatar: some View {
ZStack(alignment: .bottomTrailing) {
roomAvatarImage
.clipShape(Circle())
}
.frame(width: AvatarSize.room(on: .timeline).value, height: AvatarSize.room(on: .timeline).value)
}
@ViewBuilder private var roomAvatarImage: some View {
if let avatar = context.viewState.roomAvatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.accessibilityIdentifier("roomAvatarImage")
} else {
PlaceholderAvatarImage(text: context.viewState.roomTitle,
contentId: context.viewState.roomId)
.accessibilityIdentifier("roomAvatarPlaceholderImage")
}
LoadableAvatarImage(url: context.viewState.roomAvatarURL,
name: context.viewState.roomTitle,
contentID: context.viewState.roomId,
avatarSize: .room(on: .timeline),
imageProvider: context.imageProvider)
.accessibilityIdentifier("roomAvatarImage")
}
}
@@ -18,33 +18,29 @@ import Foundation
import SwiftUI
struct ImageRoomTimelineView: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
let timelineItem: ImageRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
if let image = timelineItem.image {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
} else if let blurhash = timelineItem.blurhash,
// Build a small blurhash image so that it's fast
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
} else {
ZStack {
Rectangle()
.foregroundColor(.element.systemGray6)
.opacity(0.3)
ProgressView(ElementL10n.loading)
.frame(maxWidth: .infinity)
}
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
LoadableImage(mediaSource: timelineItem.source,
blurhash: timelineItem.blurhash,
imageProvider: context.imageProvider) {
placeholder
}
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
}
var placeholder: some View {
ZStack {
Rectangle()
.foregroundColor(.element.systemGray6)
.opacity(0.3)
ProgressView(ElementL10n.loading)
.frame(maxWidth: .infinity)
}
.animation(.elementDefault, value: timelineItem.image)
}
}
@@ -63,8 +59,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),
source: nil,
image: UIImage(systemName: "photo")))
source: nil))
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString,
text: "Some other image",
@@ -73,8 +68,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),
source: nil,
image: nil))
source: nil))
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString,
text: "Blurhashed image",
@@ -84,7 +78,6 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
isEditable: false,
sender: .init(id: "Bob"),
source: nil,
image: nil,
aspectRatio: 0.7,
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW"))
}
@@ -1,55 +0,0 @@
//
// 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 {
private let textForImage: String
private let contentId: String?
var body: some View {
ZStack {
bgColor
Text(textForImage)
.padding(4)
.foregroundColor(.white)
.font(.system(size: 200).weight(.semibold))
.minimumScaleFactor(0.001)
}
.aspectRatio(1, contentMode: .fill)
}
init(text: String, contentId: String? = nil) {
textForImage = text.first?.uppercased() ?? ""
self.contentId = contentId
}
private var bgColor: Color {
guard let contentId else {
return .element.accent
}
return .element.avatarBackground(for: contentId)
}
}
struct PlaceholderAvatarImage_Previews: PreviewProvider {
static var previews: some View {
PlaceholderAvatarImage(text: "X", contentId: "@userid:matrix.org")
.clipShape(Circle())
.frame(width: 150, height: 100)
}
}
@@ -18,35 +18,31 @@ import Foundation
import SwiftUI
struct StickerRoomTimelineView: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
let timelineItem: StickerRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
if let image = timelineItem.image {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
} else if let blurhash = timelineItem.blurhash,
// Build a small blurhash image so that it's fast
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
} else {
ZStack {
Rectangle()
.foregroundColor(.element.systemGray6)
.opacity(0.3)
ProgressView(ElementL10n.loading)
.frame(maxWidth: .infinity)
}
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
LoadableImage(url: timelineItem.imageURL,
blurhash: timelineItem.blurhash,
imageProvider: context.imageProvider) {
placeholder
}
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
.animation(.elementDefault, value: timelineItem.image)
.accessibilityLabel(timelineItem.text)
}
private var placeholder: some View {
ZStack {
Rectangle()
.foregroundColor(.element.systemGray6)
.opacity(0.3)
ProgressView(ElementL10n.loading)
.frame(maxWidth: .infinity)
}
}
}
struct StickerRoomTimelineView_Previews: PreviewProvider {
@@ -64,8 +60,7 @@ struct StickerRoomTimelineView_Previews: PreviewProvider {
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),
imageURL: nil,
image: UIImage(systemName: "photo")))
imageURL: URL.picturesDirectory))
StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString,
text: "Some other image",
@@ -74,8 +69,7 @@ struct StickerRoomTimelineView_Previews: PreviewProvider {
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),
imageURL: nil,
image: nil))
imageURL: URL.picturesDirectory))
StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString,
text: "Blurhashed image",
@@ -84,8 +78,7 @@ struct StickerRoomTimelineView_Previews: PreviewProvider {
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),
imageURL: nil,
image: nil,
imageURL: URL.picturesDirectory,
aspectRatio: 0.7,
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW"))
}
@@ -18,42 +18,39 @@ import Foundation
import SwiftUI
struct VideoRoomTimelineView: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
let timelineItem: VideoRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
if let image = timelineItem.image {
thumbnail(with: image)
} else if let blurhash = timelineItem.blurhash,
// Build a small blurhash image so that it's fast
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
thumbnail(with: image)
} else {
ZStack {
Rectangle()
.foregroundColor(.element.systemGray6)
.opacity(0.3)
ProgressView(ElementL10n.loading)
.frame(maxWidth: .infinity)
}
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
LoadableImage(mediaSource: timelineItem.thumbnailSource,
blurhash: timelineItem.blurhash,
imageProvider: context.imageProvider) { imageView in
imageView
.overlay { playIcon }
} placeholder: {
placeholder
}
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
.animation(.elementDefault, value: timelineItem.image)
}
@ViewBuilder
private func thumbnail(with image: UIImage) -> some View {
var playIcon: some View {
Image(systemName: "play.circle.fill")
.resizable()
.frame(width: 50, height: 50)
.background(.ultraThinMaterial, in: Circle())
.foregroundColor(.white)
}
var placeholder: some View {
ZStack {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
Image(systemName: "play.circle.fill")
.resizable()
.frame(width: 50, height: 50)
.background(.ultraThinMaterial, in: Circle())
.foregroundColor(.white)
Rectangle()
.foregroundColor(.element.systemGray6)
.opacity(0.3)
ProgressView(ElementL10n.loading)
.frame(maxWidth: .infinity)
}
}
}
@@ -75,8 +72,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
sender: .init(id: "Bob"),
duration: 21,
source: nil,
thumbnailSource: nil,
image: UIImage(systemName: "photo")))
thumbnailSource: nil))
VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString,
text: "Some other video",
@@ -87,8 +83,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
sender: .init(id: "Bob"),
duration: 22,
source: nil,
thumbnailSource: nil,
image: nil))
thumbnailSource: nil))
VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString,
text: "Blurhashed video",
@@ -100,7 +95,6 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
duration: 23,
source: nil,
thumbnailSource: nil,
image: nil,
aspectRatio: 0.7,
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW"))
}
@@ -18,29 +18,19 @@ import Foundation
import SwiftUI
struct TimelineSenderAvatarView: View {
let timelineItem: EventBasedTimelineItemProtocol
@EnvironmentObject private var context: RoomScreenViewModel.Context
@ScaledMetric private var avatarSize = AvatarSize.user(on: .timeline).value
let timelineItem: EventBasedTimelineItemProtocol
var body: some View {
ZStack(alignment: .center) {
if let avatar = timelineItem.sender.avatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.overlay(Circle().stroke(Color.element.accent))
} else {
PlaceholderAvatarImage(text: timelineItem.sender.displayName ?? timelineItem.sender.id,
contentId: timelineItem.sender.id)
}
}
.clipShape(Circle())
.frame(width: avatarSize, height: avatarSize)
.overlay(
Circle()
.stroke(Color.element.background, lineWidth: 3)
)
.animation(.elementDefault, value: timelineItem.sender.avatar)
LoadableAvatarImage(url: timelineItem.sender.avatarURL,
name: timelineItem.sender.displayName,
contentID: timelineItem.sender.id,
avatarSize: .user(on: .timeline),
imageProvider: context.imageProvider)
.overlay(
Circle().stroke(Color.element.background, lineWidth: 3)
)
}
}