- while looking into fixes we noticed that we can more easily break the layout when the search field is automatically hidden
221 lines
9.3 KiB
Swift
221 lines
9.3 KiB
Swift
//
|
|
// Copyright 2024 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 Compound
|
|
import SentrySwiftUI
|
|
import SwiftUI
|
|
|
|
struct HomeScreenContent: View {
|
|
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
|
|
|
@ObservedObject var context: HomeScreenViewModel.Context
|
|
let scrollViewAdapter: ScrollViewAdapter
|
|
|
|
var body: some View {
|
|
switch context.viewState.roomListMode {
|
|
case .migration:
|
|
migrationView
|
|
default:
|
|
roomList
|
|
.sentryTrace("\(Self.self)")
|
|
}
|
|
}
|
|
|
|
private var roomList: some View {
|
|
GeometryReader { geometry in
|
|
ScrollView {
|
|
switch context.viewState.roomListMode {
|
|
case .skeletons:
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(context.viewState.visibleRooms) { room in
|
|
HomeScreenRoomCell(room: room, context: context, isSelected: false)
|
|
.redacted(reason: .placeholder)
|
|
.shimmer() // Putting this directly on the LazyVStack creates an accordion animation on iOS 16.
|
|
}
|
|
}
|
|
.disabled(true)
|
|
case .empty:
|
|
HomeScreenEmptyStateLayout(minHeight: geometry.size.height) {
|
|
topSection
|
|
|
|
HomeScreenEmptyStateView(context: context)
|
|
.layoutPriority(1)
|
|
}
|
|
case .rooms:
|
|
LazyVStack(spacing: 0) {
|
|
Section {
|
|
if !context.viewState.shouldShowEmptyFilterState {
|
|
HomeScreenRoomList(context: context)
|
|
}
|
|
} header: {
|
|
topSection
|
|
}
|
|
}
|
|
.isSearching($context.isSearchFieldFocused)
|
|
.searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always))
|
|
.compoundSearchField()
|
|
.disableAutocorrection(true)
|
|
case .migration:
|
|
EmptyView()
|
|
}
|
|
}
|
|
.introspect(.scrollView, on: .supportedVersions) { scrollView in
|
|
guard scrollView != scrollViewAdapter.scrollView else { return }
|
|
scrollViewAdapter.scrollView = scrollView
|
|
}
|
|
.onReceive(scrollViewAdapter.didScroll) { _ in
|
|
updateVisibleRange()
|
|
}
|
|
.onReceive(scrollViewAdapter.isScrolling) { _ in
|
|
updateVisibleRange()
|
|
}
|
|
.onChange(of: context.searchQuery) { _ in
|
|
updateVisibleRange()
|
|
}
|
|
.onChange(of: context.viewState.visibleRooms) { _ in
|
|
updateVisibleRange()
|
|
|
|
// We have been seeing a lot of issues around the room list not updating properly after
|
|
// rooms shifting around:
|
|
// * Tapping on the room list doesn't always take you to the right room - https://github.com/element-hq/element-x-ios/issues/2386
|
|
// * Big blank gaps in the room list - https://github.com/element-hq/element-x-ios/issues/3026
|
|
//
|
|
// We initially thought it's caused by the filters header or the geometry reader but
|
|
// the problem is still reproducible without those.
|
|
//
|
|
// As a last attempt we will manually force it to update by shifting the
|
|
// inner scroll view by a point every time the room list is updated
|
|
DispatchQueue.main.async {
|
|
guard !scrollViewAdapter.isScrolling.value, let scrollView = scrollViewAdapter.scrollView else {
|
|
return
|
|
}
|
|
|
|
let oldOffset = scrollView.contentOffset
|
|
var newOffset = scrollView.contentOffset
|
|
newOffset.y += 1
|
|
|
|
scrollView.setContentOffset(newOffset, animated: false)
|
|
scrollView.setContentOffset(oldOffset, animated: false)
|
|
}
|
|
}
|
|
.background {
|
|
Button("") {
|
|
context.send(viewAction: .globalSearch)
|
|
}
|
|
.keyboardShortcut(KeyEquivalent("k"), modifiers: [.command])
|
|
}
|
|
.overlay {
|
|
if context.viewState.shouldShowEmptyFilterState {
|
|
RoomListFiltersEmptyStateView(state: context.filtersState)
|
|
.background(.compound.bgCanvasDefault)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
.scrollDismissesKeyboard(.immediately)
|
|
.scrollDisabled(context.viewState.roomListMode == .skeletons)
|
|
.scrollBounceBehavior(context.viewState.roomListMode == .empty ? .basedOnSize : .automatic)
|
|
.animation(.elementDefault, value: context.viewState.roomListMode)
|
|
.animation(.none, value: context.viewState.visibleRooms)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var topSection: some View {
|
|
// An empty VStack causes glitches within the room list
|
|
if context.viewState.shouldShowFilters ||
|
|
context.viewState.shouldShowRecoveryKeyConfirmationBanner {
|
|
VStack(spacing: 0) {
|
|
if context.viewState.shouldShowFilters {
|
|
RoomListFiltersView(state: $context.filtersState)
|
|
}
|
|
|
|
if context.viewState.shouldShowRecoveryKeyConfirmationBanner {
|
|
HomeScreenRecoveryKeyConfirmationBanner(context: context)
|
|
}
|
|
}
|
|
.background(Color.compound.bgCanvasDefault)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var migrationView: some View {
|
|
if UIDevice.current.isPhone {
|
|
if verticalSizeClass == .compact {
|
|
migrationViewContent
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
WaitingDialog {
|
|
migrationViewContent
|
|
} bottomContent: {
|
|
EmptyView()
|
|
}
|
|
}
|
|
} else {
|
|
migrationViewContent
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
private var migrationViewContent: some View {
|
|
VStack(spacing: 16) {
|
|
ProgressView()
|
|
.tint(.compound.iconPrimary)
|
|
.padding(.bottom, 4)
|
|
|
|
Text(L10n.screenMigrationTitle.tinting(".", color: Asset.Colors.brandColor.swiftUIColor))
|
|
.minimumScaleFactor(0.01)
|
|
.font(.compound.headingXLBold)
|
|
.multilineTextAlignment(.center)
|
|
.foregroundColor(.compound.textPrimary)
|
|
|
|
Text(L10n.screenMigrationMessage)
|
|
.minimumScaleFactor(0.01)
|
|
.font(.compound.bodyLG)
|
|
.multilineTextAlignment(.center)
|
|
.foregroundColor(.compound.textPrimary)
|
|
.accessibilityIdentifier(A11yIdentifiers.migrationScreen.message)
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
|
|
/// Often times the scroll view's content size isn't correct yet when this method is called e.g. when cancelling a search
|
|
/// Dispatch it with a delay to allow the UI to update and the computations to be correct
|
|
/// Once we move to iOS 17 we should remove all of this and use scroll anchors instead
|
|
private func updateVisibleRange() {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { delayedUpdateVisibleRange() }
|
|
}
|
|
|
|
private func delayedUpdateVisibleRange() {
|
|
guard let scrollView = scrollViewAdapter.scrollView,
|
|
context.viewState.visibleRooms.count > 0 else {
|
|
return
|
|
}
|
|
|
|
guard scrollView.contentSize.height > scrollView.bounds.height else {
|
|
return
|
|
}
|
|
|
|
let adjustedContentSize = max(scrollView.contentSize.height - scrollView.contentInset.top - scrollView.contentInset.bottom, scrollView.bounds.height)
|
|
let cellHeight = adjustedContentSize / Double(context.viewState.visibleRooms.count)
|
|
|
|
let firstIndex = Int(max(0.0, scrollView.contentOffset.y + scrollView.contentInset.top) / cellHeight)
|
|
let lastIndex = Int(max(0.0, scrollView.contentOffset.y + scrollView.bounds.height) / cellHeight)
|
|
|
|
// This will be deduped and throttled on the view model layer
|
|
context.send(viewAction: .updateVisibleItemRange(range: firstIndex..<lastIndex, isScrolling: scrollViewAdapter.isScrolling.value))
|
|
}
|
|
}
|