Add an empty state on the home screen. (#1450)

This commit is contained in:
Doug
2023-08-07 10:10:42 +01:00
committed by GitHub
parent 46d5c673d4
commit 4eed77942f
5 changed files with 287 additions and 95 deletions
@@ -31,79 +31,73 @@ struct HomeScreen: View {
@State private var isSearching = false
var bottomBarVisibility: Visibility {
if lastScrollDirection == .up, context.viewState.roomListMode == .rooms {
return .automatic
} else {
return .hidden
switch context.viewState.roomListMode {
case .skeletons: return .hidden
case .empty: return .visible
case .rooms: return lastScrollDirection == .up ? .automatic : .hidden
}
}
var body: some View {
ScrollView {
if context.viewState.showSessionVerificationBanner {
sessionVerificationBanner
}
if context.viewState.hasPendingInvitations, !isSearching {
HomeScreenInvitesButton(title: L10n.actionInvitesList, hasBadge: context.viewState.hasUnreadPendingInvitations) {
context.send(viewAction: .selectInvites)
}
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.vertical, -8.0)
}
if context.viewState.roomListMode == .skeletons {
LazyVStack(spacing: 0) {
ForEach(context.viewState.visibleRooms) { room in
HomeScreenRoomCell(room: room, context: context, isSelected: false)
.redacted(reason: .placeholder)
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()
.disabled(true)
} else {
LazyVStack(spacing: 0) {
HomeScreenRoomList(context: context, isSearching: $isSearching)
}
.searchable(text: $context.searchQuery)
.compoundSearchField()
.disableAutocorrection(true)
}
}
.introspect(.scrollView, on: .iOS(.v16)) { 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) { searchQuery in
if searchQuery.isEmpty {
// Allow the view to update after changing the query
DispatchQueue.main.async {
updateVisibleRange()
.shimmer()
.disabled(true)
case .empty:
HomeScreenEmptyStateLayout(minHeight: geometry.size.height) {
topSection
HomeScreenEmptyStateView(context: context)
.layoutPriority(1)
}
case .rooms:
topSection
LazyVStack(spacing: 0) {
HomeScreenRoomList(context: context, isSearching: $isSearching)
}
.searchable(text: $context.searchQuery)
.compoundSearchField()
.disableAutocorrection(true)
}
}
}
.onReceive(scrollViewAdapter.scrollDirection) { direction in
withAnimation(.elementDefault) {
lastScrollDirection = direction
.introspect(.scrollView, on: .iOS(.v16)) { scrollView in
guard scrollView != scrollViewAdapter.scrollView else { return }
scrollViewAdapter.scrollView = scrollView
}
}
.onChange(of: context.viewState.visibleRooms) { _ in
// Give the view a chance to update
DispatchQueue.main.async {
.onReceive(scrollViewAdapter.didScroll) { _ in
updateVisibleRange()
}
.onReceive(scrollViewAdapter.isScrolling) { _ in
updateVisibleRange()
}
.onChange(of: context.searchQuery) { searchQuery in
guard searchQuery.isEmpty else { return }
// Dispatch allows the view to update after changing the query
DispatchQueue.main.async { updateVisibleRange() }
}
.onReceive(scrollViewAdapter.scrollDirection) { direction in
withAnimation(.elementDefault) { lastScrollDirection = direction }
}
.onChange(of: context.viewState.visibleRooms) { _ in
// Dispatch gives the view a chance to update
DispatchQueue.main.async { updateVisibleRange() }
}
.scrollDismissesKeyboard(.immediately)
.scrollDisabled(context.viewState.roomListMode == .skeletons)
.scrollBounceBehavior(context.viewState.roomListMode == .empty ? .basedOnSize : .automatic)
.animation(.elementDefault, value: context.viewState.showSessionVerificationBanner)
.animation(.elementDefault, value: context.viewState.roomListMode)
.animation(.none, value: context.viewState.visibleRooms)
}
.scrollDismissesKeyboard(.immediately)
.scrollDisabled(context.viewState.roomListMode == .skeletons)
.animation(.elementDefault, value: context.viewState.showSessionVerificationBanner)
.animation(.elementDefault, value: context.viewState.roomListMode)
.animation(.none, value: context.viewState.visibleRooms)
.alert(item: $context.alertInfo)
.alert(item: $context.leaveRoomAlertItem,
actions: leaveRoomAlertActions,
@@ -117,23 +111,19 @@ struct HomeScreen: View {
// MARK: - Private
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
HomeScreenUserMenuButton(context: context)
@ViewBuilder
/// The session verification banner and invites button if either are needed.
private var topSection: some View {
if context.viewState.showSessionVerificationBanner {
sessionVerificationBanner
}
ToolbarItemGroup(placement: .bottomBar) {
Spacer()
newRoomButton
}
}
private var newRoomButton: some View {
Button {
context.send(viewAction: .startChat)
} label: {
Image(systemName: "square.and.pencil")
if context.viewState.hasPendingInvitations, !isSearching {
HomeScreenInvitesButton(title: L10n.actionInvitesList, hasBadge: context.viewState.hasUnreadPendingInvitations) {
context.send(viewAction: .selectInvites)
}
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.vertical, -8.0)
}
}
@@ -173,6 +163,26 @@ struct HomeScreen: View {
.padding(.horizontal, 16)
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
HomeScreenUserMenuButton(context: context)
}
ToolbarItemGroup(placement: .bottomBar) {
Spacer()
newRoomButton
}
}
private var newRoomButton: some View {
Button {
context.send(viewAction: .startChat)
} label: {
Image(systemName: "square.and.pencil")
}
}
private func updateVisibleRange() {
guard let scrollView = scrollViewAdapter.scrollView,
context.viewState.visibleRooms.count > 0 else {
@@ -213,25 +223,37 @@ struct HomeScreen: View {
// MARK: - Previews
struct HomeScreen_Previews: PreviewProvider {
static let loadingViewModel = viewModel(.loading)
static let loadedViewModel = viewModel(.loaded(.mockRooms))
static let emptyViewModel = viewModel(.loaded([]))
static var previews: some View {
body(.loading)
body(.loaded(.mockRooms))
NavigationStack {
HomeScreen(context: loadingViewModel.context)
}
.previewDisplayName("Loading")
NavigationStack {
HomeScreen(context: loadedViewModel.context)
}
.previewDisplayName("Loaded")
NavigationStack {
HomeScreen(context: emptyViewModel.context)
}
.previewDisplayName("Empty")
}
static func body(_ state: MockRoomSummaryProviderState) -> some View {
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "John Doe",
static func viewModel(_ state: MockRoomSummaryProviderState) -> HomeScreenViewModel {
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@alice:example.com",
roomSummaryProvider: MockRoomSummaryProvider(state: state)),
mediaProvider: MockMediaProvider())
let viewModel = HomeScreenViewModel(userSession: userSession,
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL),
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(),
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
return NavigationStack {
HomeScreen(context: viewModel.context)
}
return HomeScreenViewModel(userSession: userSession,
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL),
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(),
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
}
}
@@ -0,0 +1,163 @@
//
// 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 Combine
import Compound
import SwiftUI
/// The view shown when the user isn't part of any rooms.
struct HomeScreenEmptyStateView: View {
let context: HomeScreenViewModel.Context
var body: some View {
VStack(spacing: 6) {
Text(L10n.screenRoomlistEmptyTitle)
.font(.compound.bodyLG)
.foregroundColor(.compound.textSecondary)
.multilineTextAlignment(.center)
Text(L10n.screenRoomlistEmptyMessage)
.font(.compound.bodyLG)
.foregroundColor(.compound.textSecondary)
.multilineTextAlignment(.center)
.padding(.bottom, 12)
Button { context.send(viewAction: .startChat) } label: {
Label(L10n.actionStartChat, systemImage: "square.and.pencil")
.font(.compound.bodyLGSemibold)
.foregroundColor(.compound.textOnSolidPrimary)
.padding(.vertical, 6)
.padding(.horizontal, 22)
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
}
.padding(16)
}
}
/// A custom layout for the empty state which will show it centrally with the
/// session verification banner and invites button stacked at the top.
struct HomeScreenEmptyStateLayout: Layout {
/// The vertical spacing between views in the layout.
var spacing: CGFloat = 8
/// The minimum height of the layout. This should be the height of the scroll view.
var minHeight: CGFloat = 0
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
// We keep the proposed width and replace the height with the minimum specified,
// or the total height of the subviews if it exceeds the minimum height.
let width = proposal.width ?? .greatestFiniteMagnitude
var height: CGFloat = spacing * CGFloat(max(0, subviews.count - 1))
for subview in subviews {
let size = subview.sizeThatFits(proposal)
height += size.height
}
return CGSize(width: width, height: max(minHeight, height))
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let mainView = subviews.first(where: { $0.priority > 0 })
let topViews = subviews.filter { $0 != mainView }
var y: CGFloat = bounds.minY
// Place all the top views in a vertical stack, centering horizontally.
for view in topViews {
let size = view.sizeThatFits(proposal)
let x = (bounds.width - size.width) / 2
view.place(at: CGPoint(x: x, y: y), proposal: proposal)
y += size.height + spacing
}
// Place the main view in the center if there is space, otherwise add it to the stack.
guard let mainView else { return }
let mainViewSize = mainView.sizeThatFits(proposal)
if (y + mainViewSize.height / 2) < bounds.height / 2 {
let center = CGPoint(x: bounds.midX, y: bounds.midY)
mainView.place(at: center, anchor: .center, proposal: proposal)
} else {
let x = (bounds.width - mainViewSize.width) / 2
mainView.place(at: CGPoint(x: x, y: y), proposal: proposal)
}
}
}
// MARK: - Previews
struct HomeScreenEmptyStateView_Previews: PreviewProvider {
static var previews: some View {
HomeScreenEmptyStateView(context: viewModel.context)
.previewDisplayName("View")
GeometryReader { geometry in
ScrollView {
HomeScreenEmptyStateLayout(minHeight: geometry.size.height) {
banner
HomeScreenEmptyStateView(context: viewModel.context)
.layoutPriority(1)
}
}
}
.previewDisplayName("Normal Layout")
GeometryReader { geometry in
ScrollView {
HomeScreenEmptyStateLayout(minHeight: geometry.size.height) {
banner
banner
banner
HomeScreenEmptyStateView(context: viewModel.context)
.layoutPriority(1)
}
}
}
.previewDisplayName("Constrained layout")
}
// MARK: -
static var banner: some View {
Text("This is a title that is very long")
.font(.compound.headingXLBold)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding()
.background {
RoundedRectangle(cornerRadius: 20)
.fill(Color.compound.bgSubtleSecondary)
}
.padding()
}
static let viewModel = {
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@user:example.com",
roomSummaryProvider: MockRoomSummaryProvider(state: .loaded([]))),
mediaProvider: MockMediaProvider())
return HomeScreenViewModel(userSession: userSession,
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL),
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(),
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
}()
}