mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +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:
@@ -49,4 +49,18 @@ extension AttributedString {
|
||||
replacement.link = url
|
||||
replaceSubrange(range, with: replacement)
|
||||
}
|
||||
|
||||
/// Returns a new attributed string, created by replacing any hard coded `UIFont` with
|
||||
/// a simple presentation intent. This allows simple formatting to respond to Dynamic Type.
|
||||
///
|
||||
/// Currently only supports regular and bold weights.
|
||||
func replacingFontWithPresentationIntent() -> AttributedString {
|
||||
var newValue = self
|
||||
for run in newValue.runs {
|
||||
guard let font = run.uiKit.font else { continue }
|
||||
newValue[run.range].inlinePresentationIntent = font.fontDescriptor.symbolicTraits.contains(.traitBold) ? .stronglyEmphasized : nil
|
||||
newValue[run.range].uiKit.font = nil
|
||||
}
|
||||
return newValue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//
|
||||
// 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 SwiftUI
|
||||
|
||||
/// A layout that separates the main content of a screen from the buttons shown at
|
||||
/// the bottom for a dialogs that fill the entire screen. On larger devices (iPad/Mac),
|
||||
/// the height is constrained to keep the content relatively close to the buttons. If
|
||||
/// the content overflows the space available, it will become scrollable.
|
||||
///
|
||||
/// The background color behind the buttons is read from the `backgroundStyle`
|
||||
/// environment value, so make sure to set this to match the screen's background.
|
||||
struct FullscreenDialog<Content: View, BottomContent: View>: View {
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
|
||||
/// Padding applied to the top of the content automatically. Use `UIConstants` for preset values.
|
||||
var topPadding: CGFloat = UIConstants.titleTopPaddingToNavigationBar
|
||||
/// Padding applied to the content and buttons automatically
|
||||
var horizontalPadding: CGFloat = 16
|
||||
/// The spacing between the content and the buttons.
|
||||
var spacing: CGFloat = 16
|
||||
|
||||
/// The main content shown at the top of the layout.
|
||||
@ViewBuilder var content: () -> Content
|
||||
/// The content shown at the bottom of the layout.
|
||||
@ViewBuilder var bottomContent: () -> BottomContent
|
||||
|
||||
var body: some View {
|
||||
if dynamicTypeSize < .accessibility1 {
|
||||
standardLayout
|
||||
} else {
|
||||
accessibilityLayout
|
||||
}
|
||||
}
|
||||
|
||||
/// A layout where the content scrolls with the bottom content overlaid. Used with regular font sizes.
|
||||
var standardLayout: some View {
|
||||
GeometryReader { geometry in
|
||||
ScrollView {
|
||||
VStack {
|
||||
Spacer()
|
||||
.frame(height: UIConstants.spacerHeight(in: geometry))
|
||||
|
||||
content()
|
||||
.readableFrame()
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.top, topPadding)
|
||||
}
|
||||
}
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
VStack {
|
||||
bottomContent()
|
||||
.readableFrame()
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.top, spacing)
|
||||
.padding(.bottom, UIConstants.actionButtonBottomPadding)
|
||||
|
||||
Spacer()
|
||||
.frame(height: UIConstants.spacerHeight(in: geometry))
|
||||
}
|
||||
.background()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A continuously scrolling layout used for accessibility font sizes.
|
||||
var accessibilityLayout: some View {
|
||||
GeometryReader { geometry in
|
||||
ScrollView {
|
||||
VStack {
|
||||
Spacer()
|
||||
.frame(height: UIConstants.spacerHeight(in: geometry))
|
||||
|
||||
content()
|
||||
.readableFrame()
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.top, topPadding)
|
||||
|
||||
Spacer(minLength: spacing)
|
||||
|
||||
bottomContent()
|
||||
.readableFrame()
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.bottom, UIConstants.actionButtonBottomPadding)
|
||||
|
||||
Spacer()
|
||||
.frame(height: UIConstants.spacerHeight(in: geometry))
|
||||
}
|
||||
.frame(minHeight: geometry.size.height)
|
||||
}
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FullscreenDialog_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
FullscreenDialog(topPadding: UIConstants.iconTopPaddingToNavigationBar) {
|
||||
content
|
||||
} bottomContent: {
|
||||
buttons
|
||||
}
|
||||
.background()
|
||||
.environment(\.backgroundStyle, AnyShapeStyle(Color.element.background))
|
||||
}
|
||||
|
||||
private static var content: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "globe")
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.padding()
|
||||
.background(Color.compound.bgSubtlePrimary, in: Circle())
|
||||
.padding(.bottom, 8)
|
||||
Text("Hello, World")
|
||||
.font(.compound.headingLG)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
Text("I am a subtitle")
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.padding(.bottom)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("We care about you", systemImage: "person")
|
||||
Label("Environmentally focussed", systemImage: "leaf")
|
||||
Label("All of the options", systemImage: "wrench")
|
||||
Label("Fun to use", systemImage: "logo.xbox")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static var buttons: some View {
|
||||
VStack(spacing: 16) {
|
||||
Button { } label: {
|
||||
Text("Continue")
|
||||
.font(.compound.bodyLGSemibold)
|
||||
}
|
||||
.buttonStyle(.elementAction(.xLarge))
|
||||
|
||||
Button { } label: {
|
||||
Text("More options")
|
||||
.font(.compound.bodyLGSemibold)
|
||||
.padding(14)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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