diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 74f4a2e82..aa00ab137 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 52; objects = { /* Begin PBXBuildFile section */ @@ -152,6 +152,7 @@ 8D9F646387DF656EF91EE4CB /* RoomMessageFactoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F37AB24AF5A006521D38D1 /* RoomMessageFactoryProtocol.swift */; }; 90DF83A6A347F7EE7EDE89EE /* AttributedStringBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF25E364AE85090A70AE4644 /* AttributedStringBuilderTests.swift */; }; 90EB25D13AE6EEF034BDE9D2 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D52BAA5BADB06E5E8C295D /* Assets.swift */; }; + 9118EC86286218AB00A20D26 /* ReadableFrameModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9118EC85286218AB00A20D26 /* ReadableFrameModifier.swift */; }; 93BA4A81B6D893271101F9F0 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 5986E300FC849DEAB2EE7AEB /* Introspect */; }; 94E062D08E27B0387658E364 /* SplashScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5CF94E124616FD89424B73 /* SplashScreenViewModelTests.swift */; }; 964B9D2EC38C488C360CE0C9 /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B902EA6CD3296B0E10EE432B /* HomeScreen.swift */; }; @@ -457,6 +458,7 @@ 8C37FB986891D90BEAA93EAE /* UserSessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionStore.swift; sourceTree = ""; }; 8D8169443E5AC5FF71BFB3DB /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 90733775209F4D4D366A268F /* RootRouterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootRouterType.swift; sourceTree = ""; }; + 9118EC85286218AB00A20D26 /* ReadableFrameModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReadableFrameModifier.swift; sourceTree = ""; }; 92B61C243325DC76D3086494 /* EventBriefFactoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBriefFactoryProtocol.swift; sourceTree = ""; }; 938BD1FCD9E6FF3FCFA7AB4C /* zh-CN */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "zh-CN"; path = "zh-CN.lproj/Localizable.stringsdict"; sourceTree = ""; }; 93B21E72926FACB13A186689 /* ml */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ml; path = ml.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -648,6 +650,7 @@ 052CC920F473C10B509F9FC1 /* SwiftUI */ = { isa = PBXGroup; children = ( + 9118EC84286218A300A20D26 /* Layout */, 10578D9852BA78D309A1CBDF /* ViewModel */, 328DD5DA1281F758B72006C7 /* Views */, ); @@ -1094,6 +1097,14 @@ path = UserSessionStore; sourceTree = ""; }; + 9118EC84286218A300A20D26 /* Layout */ = { + isa = PBXGroup; + children = ( + 9118EC85286218AB00A20D26 /* ReadableFrameModifier.swift */, + ); + path = Layout; + sourceTree = ""; + }; 9413F680ECDFB2B0DDB0DEF2 /* Packages */ = { isa = PBXGroup; children = ( @@ -1845,6 +1856,7 @@ 7A54700193DC1F264368746A /* UserIndicatorPresenter.swift in Sources */, 10866439ABA58CCDB5D1459D /* UserIndicatorQueue.swift in Sources */, 15D1F9C415D9C921643BA82E /* UserIndicatorRequest.swift in Sources */, + 9118EC86286218AB00A20D26 /* ReadableFrameModifier.swift in Sources */, C052A8CDC7A8E7A2D906674F /* UserIndicatorStore.swift in Sources */, 80E04BE80A89A78FBB4863BB /* UserIndicatorViewPresentable.swift in Sources */, 8AB8ED1051216546CB35FA0E /* UserSession.swift in Sources */, diff --git a/ElementX/Sources/Other/SwiftUI/Layout/ReadableFrameModifier.swift b/ElementX/Sources/Other/SwiftUI/Layout/ReadableFrameModifier.swift new file mode 100644 index 000000000..eb44a9496 --- /dev/null +++ b/ElementX/Sources/Other/SwiftUI/Layout/ReadableFrameModifier.swift @@ -0,0 +1,39 @@ +// +// 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 + +// swiftlint:disable private_over_fileprivate + +/// Positions this view within an invisible frame that fills the width of its parent view, +/// whilst limiting the width of the content to a readable size (which is customizable). +fileprivate struct ReadableFrameModifier: ViewModifier { + var maxWidth: CGFloat + + func body(content: Content) -> some View { + content + .frame(maxWidth: maxWidth) + .frame(maxWidth: .infinity) + } +} + +extension View { + /// Positions this view within an invisible frame that fills the width of its parent view, + /// whilst limiting the width of the content to a readable size (which is customizable). + func readableFrame(maxWidth: CGFloat = 600) -> some View { + modifier(ReadableFrameModifier(maxWidth: maxWidth)) + } +} diff --git a/ElementX/Sources/Screens/Authentication/UIConstants.swift b/ElementX/Sources/Screens/Authentication/UIConstants.swift index 415d9c79b..b15db271a 100644 --- a/ElementX/Sources/Screens/Authentication/UIConstants.swift +++ b/ElementX/Sources/Screens/Authentication/UIConstants.swift @@ -18,9 +18,15 @@ import SwiftUI /// Standard constants used across the app's UI. struct UIConstants { - static let maxContentWidth: CGFloat = 600 static let maxContentHeight: CGFloat = 750 /// The padding used between the top of the main content and the navigation bar. static let topPaddingToNavigationBar: CGFloat = 16 + /// The padding used between the footer and the bottom of the view. + static let actionButtonBottomPadding: CGFloat = 24 + + /// The height to use for top/bottom spacers to pad the views to fit the `maxContentHeight`. + static func spacerHeight(in geometry: GeometryProxy) -> CGFloat { + max(0, (geometry.size.height - maxContentHeight) / 2) + } } diff --git a/ElementX/Sources/Screens/SplashScreen/SplashScreenModels.swift b/ElementX/Sources/Screens/SplashScreen/SplashScreenModels.swift index 04642ac19..61a82d418 100644 --- a/ElementX/Sources/Screens/SplashScreen/SplashScreenModels.swift +++ b/ElementX/Sources/Screens/SplashScreen/SplashScreenModels.swift @@ -28,7 +28,6 @@ struct SplashScreenPageContent { let title: AttributedString let message: String let image: ImageAsset - let gradient: Gradient } // MARK: View model @@ -41,20 +40,29 @@ enum SplashScreenViewModelAction { // MARK: View struct SplashScreenViewState: BindableState { - private enum Constants { - static let gradientColors = [ - Color(red: 0.95, green: 0.98, blue: 0.96), - Color(red: 0.89, green: 0.96, blue: 0.97), - Color(red: 0.95, green: 0.89, blue: 0.97), - Color(red: 0.81, green: 0.95, blue: 0.91), - Color(red: 0.95, green: 0.98, blue: 0.96) - ] - } + + /// The colours of the background gradient shown behind the 4 pages. + private let gradientColors = [ + Color(red: 0.95, green: 0.98, blue: 0.96), + Color(red: 0.89, green: 0.96, blue: 0.97), + Color(red: 0.95, green: 0.89, blue: 0.97), + Color(red: 0.81, green: 0.95, blue: 0.91), + Color(red: 0.95, green: 0.98, blue: 0.96) + ] /// An array containing all content of the carousel pages let content: [SplashScreenPageContent] var bindings: SplashScreenBindings + /// The background gradient for all 4 pages and the hidden page at the start of the carousel. + var backgroundGradient: Gradient { + // Include the extra stop for the hidden page at the start of the carousel. + // (The last color is the right-hand stop, but we need the left-hand stop, + // so take the last but one color from the array). + let hiddenPageColor = gradientColors[gradientColors.count - 2] + return Gradient(colors: [hiddenPageColor] + gradientColors) + } + init() { // The pun doesn't translate, so we only use it for English. let locale = Locale.current @@ -62,21 +70,17 @@ struct SplashScreenViewState: BindableState { content = [ SplashScreenPageContent(title: ElementL10n.ftueAuthCarouselSecureTitle.tinting("."), - message: ElementL10n.ftueAuthCarouselSecureBody, - image: Asset.Images.splashScreenPage1, - gradient: Gradient(colors: [Constants.gradientColors[0], Constants.gradientColors[1]])), + message: ElementL10n.ftueAuthCarouselSecureBody, + image: Asset.Images.splashScreenPage1), SplashScreenPageContent(title: ElementL10n.ftueAuthCarouselControlTitle.tinting("."), - message: ElementL10n.ftueAuthCarouselControlBody, - image: Asset.Images.splashScreenPage2, - gradient: Gradient(colors: [Constants.gradientColors[1], Constants.gradientColors[2]])), + message: ElementL10n.ftueAuthCarouselControlBody, + image: Asset.Images.splashScreenPage2), SplashScreenPageContent(title: ElementL10n.ftueAuthCarouselEncryptedTitle.tinting("."), - message: ElementL10n.ftueAuthCarouselEncryptedBody, - image: Asset.Images.splashScreenPage3, - gradient: Gradient(colors: [Constants.gradientColors[2], Constants.gradientColors[3]])), + message: ElementL10n.ftueAuthCarouselEncryptedBody, + image: Asset.Images.splashScreenPage3), SplashScreenPageContent(title: page4Title.tinting("."), - message: ElementL10n.ftueAuthCarouselWorkplaceBody(ElementInfoPlist.cfBundleName), - image: Asset.Images.splashScreenPage4, - gradient: Gradient(colors: [Constants.gradientColors[3], Constants.gradientColors[4]])) + message: ElementL10n.ftueAuthCarouselWorkplaceBody(ElementInfoPlist.cfBundleName), + image: Asset.Images.splashScreenPage4) ] bindings = SplashScreenBindings() } diff --git a/ElementX/Sources/Screens/SplashScreen/View/SplashScreen.swift b/ElementX/Sources/Screens/SplashScreen/View/SplashScreen.swift index 33ec6a95b..3ec8ccc97 100644 --- a/ElementX/Sources/Screens/SplashScreen/View/SplashScreen.swift +++ b/ElementX/Sources/Screens/SplashScreen/View/SplashScreen.swift @@ -23,13 +23,12 @@ struct SplashScreen: View { // MARK: Private + @Environment(\.colorScheme) private var colorScheme @Environment(\.layoutDirection) private var layoutDirection private var isLeftToRight: Bool { layoutDirection == .leftToRight } private var pageCount: Int { viewModel.viewState.content.count } - /// The dimensions of the stack with the action buttons and page indicator. - @State private var overlayFrame: CGRect = .zero /// A timer to automatically animate the pages. @State private var pageTimer: Timer? /// The amount of offset to apply when a drag gesture is in progress. @@ -41,61 +40,53 @@ struct SplashScreen: View { var body: some View { GeometryReader { geometry in - ZStack(alignment: .leading) { + VStack(alignment: .leading) { + Spacer() + .frame(height: UIConstants.spacerHeight(in: geometry)) // The main content of the carousel - HStack(spacing: 0) { + HStack(alignment: .top, spacing: 0) { // Add a hidden page at the start of the carousel duplicating the content of the last page - SplashScreenPage(content: viewModel.viewState.content[pageCount - 1], - overlayHeight: overlayFrame.height + geometry.safeAreaInsets.bottom) + SplashScreenPage(content: viewModel.viewState.content[pageCount - 1]) .frame(width: geometry.size.width) - .tag(-1) .accessibilityIdentifier("hiddenPage") ForEach(0.. 0 ? 0 : 16) + Spacer() + .frame(height: UIConstants.spacerHeight(in: geometry)) } - .background(ViewFrameReader(frame: $overlayFrame)) + .frame(maxHeight: .infinity) + .background(background.ignoresSafeArea().offset(x: pageOffset(in: geometry))) + .gesture( + DragGesture() + .onChanged(handleDragGestureChange) + .onEnded { handleDragGestureEnded($0, viewSize: geometry.size) } + ) } + .navigationBarHidden(true) + .onAppear(perform: startTimer) + .onDisappear(perform: stopTimer) } /// The main action buttons. @@ -106,6 +97,21 @@ struct SplashScreen: View { } .buttonStyle(.elementAction(.xLarge)) } + .padding(.horizontal, 16) + .readableFrame() + } + + @ViewBuilder + /// The view's background, showing a gradient in light mode and a solid colour in dark mode. + var background: some View { + if colorScheme == .light { + LinearGradient(gradient: viewModel.viewState.backgroundGradient, + startPoint: .leading, + endPoint: .trailing) + .flipsForRightToLeftLayoutDirection(true) + } else { + Color.element.background + } } // MARK: - Animation @@ -152,6 +158,11 @@ struct SplashScreen: View { viewModel.pageIndex = -1 } + /// The offset to apply to the `HStack` of pages. + private func pageOffset(in geometry: GeometryProxy) -> CGFloat { + (CGFloat(viewModel.pageIndex + 1) * -geometry.size.width) + dragOffset + } + // MARK: - Gestures /// Whether or not a drag gesture is valid or not. diff --git a/ElementX/Sources/Screens/SplashScreen/View/SplashScreenPage.swift b/ElementX/Sources/Screens/SplashScreen/View/SplashScreenPage.swift index 82a68d316..02fa51d13 100644 --- a/ElementX/Sources/Screens/SplashScreen/View/SplashScreenPage.swift +++ b/ElementX/Sources/Screens/SplashScreen/View/SplashScreenPage.swift @@ -20,59 +20,38 @@ struct SplashScreenPage: View { // MARK: - Properties - // MARK: Private - - @Environment(\.colorScheme) private var colorScheme - // MARK: Public /// The content that this page should display. let content: SplashScreenPageContent - /// The height of the non-scrollable content in the splash screen. - let overlayHeight: CGFloat // MARK: - Views - @ViewBuilder - var backgroundGradient: some View { - if colorScheme != .dark { - LinearGradient(gradient: content.gradient, startPoint: .leading, endPoint: .trailing) - .flipsForRightToLeftLayoutDirection(true) - } - } - var body: some View { VStack { - VStack { - Image(content.image.name) - .resizable() - .scaledToFit() - .frame(maxWidth: 300) - .padding(20) - .accessibilityHidden(true) - - VStack(spacing: 8) { - Text(content.title) - .font(.element.title2B) - .foregroundColor(.element.primaryContent) - Text(content.message) - .font(.element.body) - .foregroundColor(.element.secondaryContent) - .multilineTextAlignment(.center) - } - .padding(.bottom) - - Spacer() - - // Prevent the content from clashing with the overlay content. - Spacer().frame(maxHeight: overlayHeight) + Image(content.image.name) + .resizable() + .scaledToFit() + .frame(maxWidth: 310) // This value is problematic. 300 results in dropped frames + // on iPhone 12/13 Mini. 305 the same on iPhone 12/13. As of + // iOS 15, 310 seems fine on all supported screen widths 🤞. + .padding(20) + .accessibilityHidden(true) + + VStack(spacing: 8) { + Text(content.title) + .font(.element.title2B) + .foregroundColor(.element.primaryContent) + Text(content.message) + .font(.element.body) + .foregroundColor(.element.secondaryContent) + .multilineTextAlignment(.center) } - .padding(.horizontal, 16) - .frame(maxWidth: UIConstants.maxContentWidth, - maxHeight: UIConstants.maxContentHeight) + .fixedSize(horizontal: false, vertical: true) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(backgroundGradient.ignoresSafeArea()) + .padding(.bottom) + .padding(.horizontal, 16) + .readableFrame() } } @@ -80,7 +59,7 @@ struct SplashScreenPage_Previews: PreviewProvider { static let content = SplashScreenViewState().content static var previews: some View { ForEach(0..