mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:06:00 +00:00
Authentication Layouts (#994)
* Remove unused code in OnboardingScreen. * Add FullscreenDialog layout and use in auth flow. Bump minimum OS to 16.4/13.3. * Add dynamic layout based on dynamic type size.
This commit is contained in:
@@ -38,8 +38,8 @@ struct AnalyticsPromptScreenViewState: BindableState {
|
||||
/// A collection of strings for the UI that need to be parsed from HTML
|
||||
struct AnalyticsPromptScreenStrings {
|
||||
let optInContent: AttributedString
|
||||
let point1 = AttributedStringBuilder().fromHTML(L10n.screenAnalyticsPromptDataUsage) ?? AttributedString(L10n.screenAnalyticsPromptDataUsage)
|
||||
let point2 = AttributedStringBuilder().fromHTML(L10n.screenAnalyticsPromptThirdPartySharing) ?? AttributedString(L10n.screenAnalyticsPromptThirdPartySharing)
|
||||
let point1 = AttributedStringBuilder().fromHTML(L10n.screenAnalyticsPromptDataUsage)?.replacingFontWithPresentationIntent() ?? AttributedString(L10n.screenAnalyticsPromptDataUsage)
|
||||
let point2 = AttributedStringBuilder().fromHTML(L10n.screenAnalyticsPromptThirdPartySharing)?.replacingFontWithPresentationIntent() ?? AttributedString(L10n.screenAnalyticsPromptThirdPartySharing)
|
||||
let point3 = L10n.screenAnalyticsPromptSettings
|
||||
|
||||
init(termsURL: URL) {
|
||||
|
||||
@@ -18,37 +18,16 @@ import SwiftUI
|
||||
|
||||
/// A prompt that asks the user whether they would like to enable Analytics or not.
|
||||
struct AnalyticsPromptScreen: View {
|
||||
private let horizontalPadding: CGFloat = 16
|
||||
|
||||
@ObservedObject var context: AnalyticsPromptScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ScrollView {
|
||||
Spacer()
|
||||
.frame(height: UIConstants.spacerHeight(in: geometry))
|
||||
|
||||
mainContent
|
||||
.readableFrame()
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.top, UIConstants.onboardingBreakerScreenTopPadding)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
VStack {
|
||||
buttons
|
||||
.readableFrame()
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.bottom, UIConstants.actionButtonBottomPadding)
|
||||
|
||||
Spacer()
|
||||
.frame(height: UIConstants.spacerHeight(in: geometry))
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.background(Color.element.background.ignoresSafeArea())
|
||||
}
|
||||
.background(Color.element.background.ignoresSafeArea())
|
||||
FullscreenDialog(topPadding: UIConstants.onboardingBreakerScreenTopPadding) {
|
||||
mainContent
|
||||
} bottomContent: {
|
||||
buttons
|
||||
}
|
||||
.background()
|
||||
.environment(\.backgroundStyle, AnyShapeStyle(Color.element.background))
|
||||
.navigationBarHidden(true)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
}
|
||||
@@ -95,7 +74,7 @@ struct AnalyticsPromptScreen: View {
|
||||
|
||||
/// The stack of enable/disable buttons.
|
||||
private var buttons: some View {
|
||||
VStack {
|
||||
VStack(spacing: 16) {
|
||||
Button { context.send(viewAction: .enable) } label: {
|
||||
Text(L10n.actionEnable)
|
||||
.font(.compound.bodyLGSemibold)
|
||||
@@ -106,7 +85,7 @@ struct AnalyticsPromptScreen: View {
|
||||
Button { context.send(viewAction: .disable) } label: {
|
||||
Text(L10n.actionNotNow)
|
||||
.font(.compound.bodyLGSemibold)
|
||||
.padding(12)
|
||||
.padding(14)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.analyticsPromptScreen.notNow)
|
||||
}
|
||||
|
||||
+6
-13
@@ -20,20 +20,13 @@ struct ServerConfirmationScreen: View {
|
||||
@ObservedObject var context: ServerConfirmationScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
FullscreenDialog(topPadding: UIConstants.iconTopPaddingToNavigationBar) {
|
||||
header
|
||||
.padding(.top, UIConstants.iconTopPaddingToNavigationBar)
|
||||
.padding(.horizontal, 16)
|
||||
.readableFrame()
|
||||
}
|
||||
.background(Color.element.background.ignoresSafeArea())
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
} bottomContent: {
|
||||
buttons
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 16)
|
||||
.readableFrame()
|
||||
.background(Color.element.background.ignoresSafeArea())
|
||||
}
|
||||
.background()
|
||||
.environment(\.backgroundStyle, AnyShapeStyle(Color.element.background))
|
||||
.introspectViewController { viewController in
|
||||
guard let window = viewController.view.window else { return }
|
||||
context.send(viewAction: .updateWindow(window))
|
||||
@@ -62,7 +55,7 @@ struct ServerConfirmationScreen: View {
|
||||
|
||||
/// The action buttons shown at the bottom of the view.
|
||||
var buttons: some View {
|
||||
VStack(spacing: 24) {
|
||||
VStack(spacing: 16) {
|
||||
Button { context.send(viewAction: .confirm) } label: {
|
||||
Text(L10n.actionContinue)
|
||||
}
|
||||
@@ -72,7 +65,7 @@ struct ServerConfirmationScreen: View {
|
||||
Button { context.send(viewAction: .changeServer) } label: {
|
||||
Text(L10n.screenServerConfirmationChangeServer)
|
||||
.font(.compound.bodyLGSemibold)
|
||||
.padding(.vertical, 14)
|
||||
.padding(14)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.serverConfirmationScreen.changeServer)
|
||||
}
|
||||
|
||||
@@ -33,24 +33,7 @@ enum OnboardingViewModelAction {
|
||||
case login
|
||||
}
|
||||
|
||||
struct OnboardingViewState: BindableState {
|
||||
/// An array containing all content of the carousel pages
|
||||
let content: [OnboardingPageContent]
|
||||
var bindings: OnboardingBindings
|
||||
|
||||
init() {
|
||||
content = [
|
||||
OnboardingPageContent(title: L10n.screenOnboardingWelcomeTitle.tinting(".", color: .element.accent),
|
||||
message: L10n.screenOnboardingWelcomeSubtitle(InfoPlistReader.main.bundleDisplayName),
|
||||
image: Asset.Images.onboardingAppLogo)
|
||||
]
|
||||
bindings = OnboardingBindings()
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingBindings {
|
||||
var pageIndex = 0
|
||||
}
|
||||
struct OnboardingViewState: BindableState { }
|
||||
|
||||
enum OnboardingViewAction {
|
||||
case login
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
struct OnboardingPageIndicator: View {
|
||||
/// The number of pages that are shown.
|
||||
let pageCount: Int
|
||||
/// The index of the current page
|
||||
let pageIndex: Int
|
||||
|
||||
internal init(pageCount: Int, pageIndex: Int) {
|
||||
self.pageCount = pageCount
|
||||
|
||||
// Clamp the page index to handle the hidden page.
|
||||
if pageIndex == -1 {
|
||||
self.pageIndex = pageCount - 1
|
||||
} else {
|
||||
self.pageIndex = pageIndex % pageCount
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
ForEach(0..<pageCount, id: \.self) { index in
|
||||
Circle()
|
||||
.frame(width: 8, height: 8)
|
||||
.foregroundColor(index == pageIndex ? .accentColor : .element.quaternaryContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
struct OnboardingPageView: View {
|
||||
/// The content that this page should display.
|
||||
let content: OnboardingPageContent
|
||||
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if verticalSizeClass == .regular {
|
||||
Spacer()
|
||||
|
||||
Image(content.image.name)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.padding(60)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Spacer()
|
||||
|
||||
Text(content.title)
|
||||
.font(.compound.headingLGBold)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(content.message)
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundColor(.element.secondaryContent)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.bottom)
|
||||
.padding(.horizontal, 16)
|
||||
.readableFrame()
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingPage_Previews: PreviewProvider {
|
||||
static let content = OnboardingViewState().content
|
||||
static var previews: some View {
|
||||
ForEach(0..<content.count, id: \.self) { index in
|
||||
OnboardingPageView(content: content[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,16 +19,7 @@ import SwiftUI
|
||||
|
||||
/// The screen shown at the beginning of the onboarding flow.
|
||||
struct OnboardingScreen: View {
|
||||
@Environment(\.layoutDirection) private var layoutDirection
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
private var isLeftToRight: Bool { layoutDirection == .leftToRight }
|
||||
private var pageCount: Int { context.viewState.content.count }
|
||||
|
||||
/// 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.
|
||||
@State private var dragOffset: CGFloat = .zero
|
||||
|
||||
@ObservedObject var context: OnboardingViewModel.Context
|
||||
|
||||
@@ -38,55 +29,66 @@ struct OnboardingScreen: View {
|
||||
.accessibilityHidden(true)
|
||||
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
VStack(alignment: .leading) {
|
||||
Spacer()
|
||||
.frame(height: UIConstants.spacerHeight(in: geometry))
|
||||
|
||||
// The main content of the carousel
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
// Add a hidden page at the start of the carousel duplicating the content of the last page
|
||||
OnboardingPageView(content: context.viewState.content[pageCount - 1])
|
||||
.frame(width: geometry.size.width)
|
||||
.accessibilityIdentifier(A11yIdentifiers.onboardingScreen.hidden)
|
||||
|
||||
ForEach(0..<pageCount, id: \.self) { index in
|
||||
OnboardingPageView(content: context.viewState.content[index])
|
||||
.frame(width: geometry.size.width)
|
||||
}
|
||||
}
|
||||
.offset(x: pageOffset(in: geometry))
|
||||
|
||||
Spacer()
|
||||
|
||||
if pageCount > 1 {
|
||||
OnboardingPageIndicator(pageCount: pageCount, pageIndex: context.pageIndex)
|
||||
.frame(width: geometry.size.width)
|
||||
.padding(.bottom)
|
||||
}
|
||||
|
||||
buttons
|
||||
.frame(width: geometry.size.width)
|
||||
.padding(.bottom, UIConstants.actionButtonBottomPadding)
|
||||
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16)
|
||||
|
||||
Spacer()
|
||||
.frame(height: UIConstants.spacerHeight(in: geometry))
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onChanged(handleDragGestureChange)
|
||||
.onEnded { handleDragGestureEnded($0, viewSize: geometry.size) }
|
||||
)
|
||||
VStack(alignment: .leading) {
|
||||
Spacer()
|
||||
.frame(height: UIConstants.spacerHeight(in: geometry))
|
||||
|
||||
content
|
||||
.frame(width: geometry.size.width)
|
||||
.accessibilityIdentifier(A11yIdentifiers.onboardingScreen.hidden)
|
||||
|
||||
Spacer()
|
||||
|
||||
buttons
|
||||
.frame(width: geometry.size.width)
|
||||
.padding(.bottom, UIConstants.actionButtonBottomPadding)
|
||||
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16)
|
||||
|
||||
Spacer()
|
||||
.frame(height: UIConstants.spacerHeight(in: geometry))
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.onAppear(perform: startTimer)
|
||||
.onDisappear(perform: stopTimer)
|
||||
}
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
VStack {
|
||||
if verticalSizeClass == .regular {
|
||||
Spacer()
|
||||
|
||||
Image(Asset.Images.onboardingAppLogo.name)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.padding(60)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Spacer()
|
||||
|
||||
Text(L10n.screenOnboardingWelcomeTitle)
|
||||
.font(.compound.headingLGBold)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(L10n.screenOnboardingWelcomeSubtitle(InfoPlistReader.main.bundleDisplayName))
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundColor(.element.secondaryContent)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.bottom)
|
||||
.padding(.horizontal, 16)
|
||||
.readableFrame()
|
||||
}
|
||||
|
||||
/// The main action buttons.
|
||||
var buttons: some View {
|
||||
VStack(spacing: 12) {
|
||||
@@ -99,103 +101,6 @@ struct OnboardingScreen: View {
|
||||
.padding(.horizontal, verticalSizeClass == .compact ? 128 : 24)
|
||||
.readableFrame()
|
||||
}
|
||||
|
||||
// MARK: - Animation
|
||||
|
||||
/// Starts the animation timer for an automatic carousel effect.
|
||||
private func startTimer() {
|
||||
guard pageTimer == nil, pageCount > 1 else { return }
|
||||
|
||||
pageTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
|
||||
if context.pageIndex == pageCount - 1 {
|
||||
showHiddenPage()
|
||||
|
||||
withElementAnimation(.easeInOut(duration: 0.7)) {
|
||||
showNextPage()
|
||||
}
|
||||
} else {
|
||||
withElementAnimation(.easeInOut(duration: 0.7)) {
|
||||
showNextPage()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops the animation timer for manual interaction.
|
||||
private func stopTimer() {
|
||||
guard let pageTimer else { return }
|
||||
|
||||
self.pageTimer = nil
|
||||
pageTimer.invalidate()
|
||||
}
|
||||
|
||||
private func showNextPage() {
|
||||
// Wrap back round to the first page index when reaching the end.
|
||||
context.pageIndex = (context.pageIndex + 1) % context.viewState.content.count
|
||||
}
|
||||
|
||||
private func showPreviousPage() {
|
||||
// Prevent the hidden page at index -1 from being shown.
|
||||
context.pageIndex = max(0, context.pageIndex - 1)
|
||||
}
|
||||
|
||||
private func showHiddenPage() {
|
||||
// Hidden page for a nicer animation when looping back to the start.
|
||||
context.pageIndex = -1
|
||||
}
|
||||
|
||||
/// The offset to apply to the `HStack` of pages.
|
||||
private func pageOffset(in geometry: GeometryProxy) -> CGFloat {
|
||||
(CGFloat(context.pageIndex + 1) * -geometry.size.width) + dragOffset
|
||||
}
|
||||
|
||||
// MARK: - Gestures
|
||||
|
||||
/// Whether or not a drag gesture is valid or not.
|
||||
/// - Parameter width: The gesture's translation width.
|
||||
/// - Returns: `true` if there is another page to drag to.
|
||||
private func shouldSwipeForTranslation(_ width: CGFloat) -> Bool {
|
||||
if context.pageIndex == 0 {
|
||||
return isLeftToRight ? width < 0 : width > 0
|
||||
} else if context.pageIndex == pageCount - 1 {
|
||||
return isLeftToRight ? width > 0 : width < 0
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/// Updates the `dragOffset` based on the gesture's value.
|
||||
/// - Parameter drag: The drag gesture value to handle.
|
||||
private func handleDragGestureChange(_ drag: DragGesture.Value) {
|
||||
guard shouldSwipeForTranslation(drag.translation.width) else { return }
|
||||
|
||||
stopTimer()
|
||||
|
||||
// Animate the change over a few frames to smooth out any stuttering.
|
||||
withElementAnimation(.linear(duration: 0.05)) {
|
||||
dragOffset = isLeftToRight ? drag.translation.width : -drag.translation.width
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the drag offset and informs the view model to switch to another page if necessary.
|
||||
/// - Parameter viewSize: The size of the view in which the gesture took place.
|
||||
private func handleDragGestureEnded(_ drag: DragGesture.Value, viewSize: CGSize) {
|
||||
guard shouldSwipeForTranslation(drag.predictedEndTranslation.width) else {
|
||||
// Reset the offset just in case.
|
||||
withElementAnimation { dragOffset = 0 }
|
||||
return
|
||||
}
|
||||
|
||||
withElementAnimation(.easeInOut(duration: 0.2)) {
|
||||
if drag.predictedEndTranslation.width < -viewSize.width / 2 {
|
||||
showNextPage()
|
||||
} else if drag.predictedEndTranslation.width > viewSize.width / 2 {
|
||||
showPreviousPage()
|
||||
}
|
||||
|
||||
dragOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
Reference in New Issue
Block a user