mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
Avoid flashing the room list skeletons on launch
On a cold launch the home screen appeared just before the cached rooms published, briefly showing skeletons that were immediately replaced. The home screen now starts in a new awaitingCachedRooms mode, keeping the list blank while a cached room list is about to publish and falling back to the skeletons after 500ms. Without a cache the skeletons show straight away as before.
This commit is contained in:
@@ -53,12 +53,16 @@ enum HomeScreenViewAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum HomeScreenRoomListMode: CustomStringConvertible {
|
enum HomeScreenRoomListMode: CustomStringConvertible {
|
||||||
|
/// Shows nothing briefly on launch as the cached rooms usually publish in time to go straight to them.
|
||||||
|
case awaitingCachedRooms
|
||||||
case skeletons
|
case skeletons
|
||||||
case empty
|
case empty
|
||||||
case rooms
|
case rooms
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .awaitingCachedRooms:
|
||||||
|
return "Awaiting cached rooms"
|
||||||
case .skeletons:
|
case .skeletons:
|
||||||
return "Showing placeholders"
|
return "Showing placeholders"
|
||||||
case .empty:
|
case .empty:
|
||||||
@@ -96,7 +100,7 @@ struct HomeScreenViewState: BindableState {
|
|||||||
var shouldShowNewSoundBanner = false
|
var shouldShowNewSoundBanner = false
|
||||||
|
|
||||||
var rooms: [HomeScreenRoom] = []
|
var rooms: [HomeScreenRoom] = []
|
||||||
var roomListMode: HomeScreenRoomListMode = .skeletons
|
var roomListMode: HomeScreenRoomListMode = .awaitingCachedRooms
|
||||||
|
|
||||||
var hasPendingInvitations = false
|
var hasPendingInvitations = false
|
||||||
|
|
||||||
@@ -113,7 +117,7 @@ struct HomeScreenViewState: BindableState {
|
|||||||
var isRoomListSearchEnabled = true
|
var isRoomListSearchEnabled = true
|
||||||
|
|
||||||
var visibleRooms: [HomeScreenRoom] {
|
var visibleRooms: [HomeScreenRoom] {
|
||||||
if roomListMode == .skeletons {
|
if roomListMode == .skeletons || roomListMode == .awaitingCachedRooms {
|
||||||
return placeholderRooms
|
return placeholderRooms
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -181,6 +181,13 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
|||||||
updateRoomListMode(with: roomSummaryProvider.statePublisher.value,
|
updateRoomListMode(with: roomSummaryProvider.statePublisher.value,
|
||||||
hasRooms: !roomSummaryProvider.roomListPublisher.value.isEmpty)
|
hasRooms: !roomSummaryProvider.roomListPublisher.value.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fall back to the skeletons if the cached rooms take too long to publish.
|
||||||
|
Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
|
guard let self, state.roomListMode == .awaitingCachedRooms else { return }
|
||||||
|
state.roomListMode = .skeletons
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public
|
// MARK: - Public
|
||||||
@@ -342,8 +349,8 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
|||||||
.empty // Loaded, there are no rooms at all.
|
.empty // Loaded, there are no rooms at all.
|
||||||
} else if hasRooms || isFiltering {
|
} else if hasRooms || isFiltering {
|
||||||
.rooms // Loaded and the summaries have published (or filtered down to nothing).
|
.rooms // Loaded and the summaries have published (or filtered down to nothing).
|
||||||
} else if state.roomListMode == .skeletons {
|
} else if state.roomListMode == .skeletons || state.roomListMode == .awaitingCachedRooms {
|
||||||
.skeletons // Loaded but nothing published yet, flipping to .rooms would flash an empty list.
|
state.roomListMode // Loaded but nothing published yet, flipping to .rooms would flash an empty list.
|
||||||
} else {
|
} else {
|
||||||
.rooms
|
.rooms
|
||||||
}
|
}
|
||||||
@@ -352,7 +359,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if roomListMode == .rooms, state.roomListMode == .skeletons {
|
if roomListMode == .rooms, state.roomListMode == .skeletons || state.roomListMode == .awaitingCachedRooms {
|
||||||
analyticsService.signpost.finishTransaction(.cachedRoomList)
|
analyticsService.signpost.finishTransaction(.cachedRoomList)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ struct HomeScreen_Previews: PreviewProvider, TestablePreview {
|
|||||||
let userID = "@alice:example.com"
|
let userID = "@alice:example.com"
|
||||||
|
|
||||||
let roomSummaryProviderState: RoomSummaryProviderMockConfigurationState = switch mode {
|
let roomSummaryProviderState: RoomSummaryProviderMockConfigurationState = switch mode {
|
||||||
case .skeletons:
|
case .awaitingCachedRooms, .skeletons:
|
||||||
.loading
|
.loading
|
||||||
case .empty:
|
case .empty:
|
||||||
.loaded([])
|
.loaded([])
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ struct HomeScreenContent: View {
|
|||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
switch context.viewState.roomListMode {
|
switch context.viewState.roomListMode {
|
||||||
case .skeletons:
|
case .awaitingCachedRooms, .skeletons:
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
ForEach(context.viewState.visibleRooms) { room in
|
ForEach(context.viewState.visibleRooms) { room in
|
||||||
HomeScreenRoomCell(room: room, isSelected: false, mediaProvider: context.mediaProvider, action: context.send)
|
HomeScreenRoomCell(room: room, isSelected: false, mediaProvider: context.mediaProvider, action: context.send)
|
||||||
@@ -34,6 +34,8 @@ struct HomeScreenContent: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(true)
|
.disabled(true)
|
||||||
|
// Laid out but hidden while awaiting so the rooms replace it without a growing animation.
|
||||||
|
.opacity(context.viewState.roomListMode == .awaitingCachedRooms ? 0 : 1)
|
||||||
.accessibilityRepresentation {
|
.accessibilityRepresentation {
|
||||||
Text(L10n.commonLoading)
|
Text(L10n.commonLoading)
|
||||||
}
|
}
|
||||||
@@ -103,7 +105,7 @@ struct HomeScreenContent: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scrollDismissesKeyboard(.immediately)
|
.scrollDismissesKeyboard(.immediately)
|
||||||
.scrollDisabled(context.viewState.roomListMode == .skeletons)
|
.scrollDisabled(context.viewState.roomListMode == .skeletons || context.viewState.roomListMode == .awaitingCachedRooms)
|
||||||
.scrollBounceBehavior(context.viewState.roomListMode == .empty ? .basedOnSize : .automatic)
|
.scrollBounceBehavior(context.viewState.roomListMode == .empty ? .basedOnSize : .automatic)
|
||||||
.animation(.elementDefault, value: context.viewState.roomListMode)
|
.animation(.elementDefault, value: context.viewState.roomListMode)
|
||||||
.animation(.none, value: context.viewState.visibleRooms)
|
.animation(.none, value: context.viewState.visibleRooms)
|
||||||
|
|||||||
@@ -421,6 +421,25 @@ final class HomeScreenViewModelTests {
|
|||||||
try await deferred.fulfill()
|
try await deferred.fulfill()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func roomListModeGoesStraightToTheCachedRooms() async throws {
|
||||||
|
let (roomListSubject, _) = setupViewModelWithManualProvider(state: .loaded(totalNumberOfRooms: 8))
|
||||||
|
#expect(context.viewState.roomListMode == .awaitingCachedRooms)
|
||||||
|
|
||||||
|
let deferred = deferFulfillment(context.$viewState) { $0.roomListMode == .rooms }
|
||||||
|
roomListSubject.send(.mockRooms)
|
||||||
|
try await deferred.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func roomListModeShowsSkeletonsWhenTheCachedRoomsAreSlow() async throws {
|
||||||
|
_ = setupViewModelWithManualProvider(state: .loaded(totalNumberOfRooms: 8))
|
||||||
|
#expect(context.viewState.roomListMode == .awaitingCachedRooms)
|
||||||
|
|
||||||
|
let deferred = deferFulfillment(context.$viewState) { $0.roomListMode == .skeletons }
|
||||||
|
try await deferred.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func roomListModeDoesntReturnToSkeletonsWhenTheRoomsAreFilteredOut() async throws {
|
func roomListModeDoesntReturnToSkeletonsWhenTheRoomsAreFilteredOut() async throws {
|
||||||
let (roomListSubject, stateSubject) = setupViewModelWithManualProvider()
|
let (roomListSubject, stateSubject) = setupViewModelWithManualProvider()
|
||||||
@@ -437,9 +456,9 @@ final class HomeScreenViewModelTests {
|
|||||||
try await failure.fulfill()
|
try await failure.fulfill()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupViewModelWithManualProvider() -> (CurrentValueSubject<[RoomSummary], Never>, CurrentValueSubject<RoomSummaryProviderState, Never>) {
|
private func setupViewModelWithManualProvider(state: RoomSummaryProviderState = .notLoaded) -> (CurrentValueSubject<[RoomSummary], Never>, CurrentValueSubject<RoomSummaryProviderState, Never>) {
|
||||||
let roomListSubject = CurrentValueSubject<[RoomSummary], Never>([])
|
let roomListSubject = CurrentValueSubject<[RoomSummary], Never>([])
|
||||||
let stateSubject = CurrentValueSubject<RoomSummaryProviderState, Never>(.notLoaded)
|
let stateSubject = CurrentValueSubject<RoomSummaryProviderState, Never>(state)
|
||||||
|
|
||||||
let provider = RoomSummaryProviderMock()
|
let provider = RoomSummaryProviderMock()
|
||||||
provider.roomListPublisher = roomListSubject.asCurrentValuePublisher()
|
provider.roomListPublisher = roomListSubject.asCurrentValuePublisher()
|
||||||
|
|||||||
Reference in New Issue
Block a user