From 4eed77942f424107a144968b3fe0e1f07961cee7 Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Mon, 7 Aug 2023 10:10:42 +0100 Subject: [PATCH] Add an empty state on the home screen. (#1450) --- ElementX.xcodeproj/project.pbxproj | 6 +- .../Screens/HomeScreen/HomeScreenModels.swift | 7 +- .../HomeScreen/HomeScreenViewModel.swift | 2 +- .../Screens/HomeScreen/View/HomeScreen.swift | 204 ++++++++++-------- .../View/HomeScreenEmptyStateView.swift | 163 ++++++++++++++ 5 files changed, 287 insertions(+), 95 deletions(-) create mode 100644 ElementX/Sources/Screens/HomeScreen/View/HomeScreenEmptyStateView.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index f6f2c63a3..eda445e34 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -324,6 +324,7 @@ 76BA28216FBAF83B2D86A027 /* InvitesScreenCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2A71915C1F075E403F559C /* InvitesScreenCell.swift */; }; 7756C4E90CABE6F14F7920A0 /* BugReportUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6FEA87EA3752203065ECE27 /* BugReportUITests.swift */; }; 77920AFA8091AC6B9F190C90 /* Signposter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752A0EB49BF5BCEA37EDF7A3 /* Signposter.swift */; }; + 77BB228AEA861E50FFD6A228 /* HomeScreenEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FEA560929DD73FFEF8C3DF /* HomeScreenEmptyStateView.swift */; }; 77C1A2F49CD90D3EFDF376E5 /* MapTilerURLBuildersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376D941BF8BB294389C0DE24 /* MapTilerURLBuildersTests.swift */; }; 77D7DAA41AAB36800C1F2E2D /* RoomTimelineProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 095AED4CF56DFF3EB7BB84C8 /* RoomTimelineProviderProtocol.swift */; }; 77FACC29F98FE2E65BBB6A5F /* ServerSelectionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 054F469E433864CC6FE6EE8E /* ServerSelectionUITests.swift */; }; @@ -1335,6 +1336,7 @@ C070FD43DC6BF4E50217965A /* LocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationTests.swift; sourceTree = ""; }; C08E9043618AE5B0BF7B07E1 /* TemplateScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateScreenViewModelTests.swift; sourceTree = ""; }; C0900BBF0A5D5D775E917C70 /* EventBasedMessageTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBasedMessageTimelineItemProtocol.swift; sourceTree = ""; }; + C0FEA560929DD73FFEF8C3DF /* HomeScreenEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenEmptyStateView.swift; sourceTree = ""; }; C1198B925F4A88DA74083662 /* OnboardingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewModel.swift; sourceTree = ""; }; C14D83B2B7CD5501A0089EFC /* LayoutDirection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutDirection.swift; sourceTree = ""; }; C1511766C534367700C8DD75 /* RoomNotificationModeProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationModeProxy.swift; sourceTree = ""; }; @@ -2209,6 +2211,7 @@ isa = PBXGroup; children = ( B902EA6CD3296B0E10EE432B /* HomeScreen.swift */, + C0FEA560929DD73FFEF8C3DF /* HomeScreenEmptyStateView.swift */, 24227FF9A2797F6EA7F69CDD /* HomeScreenInvitesButton.swift */, ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */, C7661EFFCAA307A97D71132A /* HomeScreenRoomList.swift */, @@ -3670,7 +3673,7 @@ path = Timeline; sourceTree = ""; }; - "TEMP_F85F8A84-6E1F-4213-A180-41C5B49354D2" /* element-x-ios */ = { + "TEMP_9E86D325-D1C7-441E-B1F2-A3D93615813E" /* element-x-ios */ = { isa = PBXGroup; children = ( 41553551C55AD59885840F0E /* secrets.xcconfig */, @@ -4364,6 +4367,7 @@ 4295E5F850897710A51AE114 /* GeoURI.swift in Sources */, 964B9D2EC38C488C360CE0C9 /* HomeScreen.swift in Sources */, 8CC12086CBF91A7E10CDC205 /* HomeScreenCoordinator.swift in Sources */, + 77BB228AEA861E50FFD6A228 /* HomeScreenEmptyStateView.swift in Sources */, 64C373ACCFA26D42BA45CFAD /* HomeScreenInvitesButton.swift in Sources */, 8810A2A30A68252EBB54EE05 /* HomeScreenModels.swift in Sources */, 0AE0AB1952F186EB86719B4F /* HomeScreenRoomCell.swift in Sources */, diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift index 84d5a1b09..b6361c47b 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift @@ -51,14 +51,17 @@ enum HomeScreenViewAction { enum HomeScreenRoomListMode: CustomStringConvertible { case skeletons + case empty case rooms var description: String { switch self { - case .rooms: - return "Showing rooms" case .skeletons: return "Showing placeholders" + case .empty: + return "Showing empty state" + case .rooms: + return "Showing rooms" } } } diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index 5b96bb47a..022d34380 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -92,7 +92,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol if isLoadingData { roomListMode = .skeletons } else if hasNoRooms { - roomListMode = .skeletons + roomListMode = .empty } else { roomListMode = .rooms } diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift index 4ce7cd67a..6e33c9577 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift @@ -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(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(nil).asCurrentValuePublisher(), + appSettings: ServiceLocator.shared.settings, + analytics: ServiceLocator.shared.analytics, + userIndicatorController: ServiceLocator.shared.userIndicatorController) } } diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenEmptyStateView.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenEmptyStateView.swift new file mode 100644 index 000000000..9fe9d0e4d --- /dev/null +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenEmptyStateView.swift @@ -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(nil).asCurrentValuePublisher(), + appSettings: ServiceLocator.shared.settings, + analytics: ServiceLocator.shared.analytics, + userIndicatorController: ServiceLocator.shared.userIndicatorController) + }() +}