diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index e055bfc89..4d617e42e 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -117,6 +117,9 @@ 0770DE5F28D0B22C006D8A5D /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE5E28D0B22C006D8A5D /* Strings.swift */; }; 0770DE6128D0B2CB006D8A5D /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE6028D0B2CB006D8A5D /* Assets.swift */; }; 07DDFCBD29A780BB00572595 /* UINavigationController+Animation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07DDFCBC29A780BB00572595 /* UINavigationController+Animation.swift */; }; + BA593F1C2AF8E498009ADB51 /* ScrollSlidingTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */; }; + BA593F1E2AF8E4A0009ADB51 /* FrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA593F1D2AF8E4A0009ADB51 /* FrameReader.swift */; }; + BAAD62C62AFCF00B000E6103 /* CustomDisclosureGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAAD62C52AFCF00B000E6103 /* CustomDisclosureGroup.swift */; }; 07E0939F2B308D2800F1E4B2 /* Data_Certificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E0939E2B308D2800F1E4B2 /* Data_Certificate.swift */; }; A53A32352B233DEC005FE38A /* ThemeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A32342B233DEC005FE38A /* ThemeConfig.swift */; }; BA30427F2B20B320009B64B7 /* SocialAuthError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA30427D2B20B299009B64B7 /* SocialAuthError.swift */; }; @@ -292,6 +295,9 @@ 3B74C6685E416657F3C5F5A8 /* Pods-App-Core.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.releaseprod.xcconfig"; sourceTree = ""; }; 60153262DBC2F9E660D7E11B /* Pods-App-Core.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.release.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.release.xcconfig"; sourceTree = ""; }; 9D5B06CAA99EA5CD49CBE2BB /* Pods-App-Core.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugdev.xcconfig"; sourceTree = ""; }; + BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollSlidingTabBar.swift; sourceTree = ""; }; + BA593F1D2AF8E4A0009ADB51 /* FrameReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrameReader.swift; sourceTree = ""; }; + BAAD62C52AFCF00B000E6103 /* CustomDisclosureGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDisclosureGroup.swift; sourceTree = ""; }; A53A32342B233DEC005FE38A /* ThemeConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThemeConfig.swift; sourceTree = ""; }; BA30427D2B20B299009B64B7 /* SocialAuthError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SocialAuthError.swift; sourceTree = ""; }; BA76135B2B21BC7300B599B7 /* SocialAuthResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthResponse.swift; sourceTree = ""; }; @@ -637,6 +643,7 @@ 027BD3C42909707700392132 /* Shake.swift */, 023A1135291432B200D0D354 /* RegistrationTextField.swift */, 023A1137291432FD00D0D354 /* FieldConfiguration.swift */, + BA593F1A2AF8E487009ADB51 /* ScrollSlidingTabBar */, BAAD62C52AFCF00B000E6103 /* CustomDisclosureGroup.swift */, BA8FA6672AD59A5700EA029A /* SocialAuthButton.swift */, 02E93F862AEBAED4006C4750 /* AppReview */, @@ -673,6 +680,15 @@ path = Providers; sourceTree = ""; }; + BA593F1A2AF8E487009ADB51 /* ScrollSlidingTabBar */ = { + isa = PBXGroup; + children = ( + BA593F1D2AF8E4A0009ADB51 /* FrameReader.swift */, + BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */, + ); + path = ScrollSlidingTabBar; + sourceTree = ""; + }; C9DFE47E699CFFA85A77AF2C /* Pods */ = { isa = PBXGroup; children = ( @@ -945,7 +961,9 @@ E0D586362B314CD3009B4BA7 /* LogistrationBottomView.swift in Sources */, 0727878128D25EFD002E9142 /* SnackBarView.swift in Sources */, 021D924828DC860C00ACC565 /* Data_UserProfile.swift in Sources */, + BA593F1C2AF8E498009ADB51 /* ScrollSlidingTabBar.swift in Sources */, 070019AC28F6FD0100D5FC78 /* CourseDetailBlock.swift in Sources */, + BA593F1E2AF8E4A0009ADB51 /* FrameReader.swift in Sources */, 0727877028D23411002E9142 /* Config.swift in Sources */, CFC84952299F8B890055E497 /* Debounce.swift in Sources */, 0236F3B728F4351E0050F09B /* CourseButton.swift in Sources */, diff --git a/Core/Core/Configuration/Config/FeaturesConfig.swift b/Core/Core/Configuration/Config/FeaturesConfig.swift index eec9e9853..b07cd3c6d 100644 --- a/Core/Core/Configuration/Config/FeaturesConfig.swift +++ b/Core/Core/Configuration/Config/FeaturesConfig.swift @@ -15,7 +15,7 @@ private enum FeaturesKeys: String { public class FeaturesConfig: NSObject { public var whatNewEnabled: Bool public var startupScreenEnabled: Bool - + init(dictionary: [String: Any]) { whatNewEnabled = dictionary[FeaturesKeys.whatNewEnabled.rawValue] as? Bool ?? false startupScreenEnabled = dictionary[FeaturesKeys.startupScreenEnabled.rawValue] as? Bool ?? false diff --git a/Core/Core/Configuration/Config/UIComponentsConfig.swift b/Core/Core/Configuration/Config/UIComponentsConfig.swift index da05749e5..4dd4f8be6 100644 --- a/Core/Core/Configuration/Config/UIComponentsConfig.swift +++ b/Core/Core/Configuration/Config/UIComponentsConfig.swift @@ -9,16 +9,19 @@ import Foundation private enum Keys: String { case courseNestedListEnabled = "COURSE_NESTED_LIST_ENABLED" + case courseTopTabBarEnabled = "COURSE_TOP_TAB_BAR_ENABLED" case courseBannerEnabled = "COURSE_BANNER_ENABLED" } public class UIComponentsConfig: NSObject { public var courseNestedListEnabled: Bool = false public var courseBannerEnabled: Bool + public var courseTopTabBarEnabled: Bool init(dictionary: [String: Any]) { courseNestedListEnabled = dictionary[Keys.courseNestedListEnabled.rawValue] as? Bool ?? false courseBannerEnabled = dictionary[Keys.courseBannerEnabled.rawValue] as? Bool ?? false + courseTopTabBarEnabled = dictionary[Keys.courseTopTabBarEnabled.rawValue] as? Bool ?? false super.init() } } diff --git a/Core/Core/Extensions/ViewExtension.swift b/Core/Core/Extensions/ViewExtension.swift index fb9654518..23b37eed9 100644 --- a/Core/Core/Extensions/ViewExtension.swift +++ b/Core/Core/Extensions/ViewExtension.swift @@ -240,6 +240,21 @@ public extension View { } } +public extension View { + /// Applies the given transform if the given condition evaluates to `true`. + /// - Parameters: + /// - condition: The condition to evaluate. + /// - transform: The transform to apply to the source `View`. + /// - Returns: Either the original `View` or the modified `View` if the condition is `true`. + @ViewBuilder func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { + if condition { + transform(self) + } else { + self + } + } +} + private struct FirstAppear: ViewModifier { let action: () -> Void diff --git a/Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift b/Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift new file mode 100644 index 000000000..5cc0ff18f --- /dev/null +++ b/Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift @@ -0,0 +1,45 @@ +// +// FrameReader.swift +// +// +// Created by Eugene Yatsenko on 06/11/2023. +// + +import SwiftUI + +extension View { + /// - Parameters: + /// - id: used to differentiate a view and its ancestor if they both call `readFrame` + /// - Note: `onChange` maybe called with duplicated values + public func readFrame( + in space: CoordinateSpace, + id: String = "shared", + onChange: @escaping (CGRect) -> Void + ) -> some View { + background( + GeometryReader { proxy in + Color + .clear + .preference( + key: FramePreferenceKey.self, + value: [.init(space: space, id: id): proxy.frame(in: space)]) + } + ) + .onPreferenceChange(FramePreferenceKey.self) { + onChange($0[.init(space: space, id: id)] ?? .zero) + } + } +} + +private struct FramePreferenceKey: PreferenceKey { + static var defaultValue: [PreferenceValueKey: CGRect] = [:] + + static func reduce(value: inout [PreferenceValueKey: CGRect], nextValue: () -> [PreferenceValueKey: CGRect]) { + value.merge(nextValue()) { $1 } + } +} + +private struct PreferenceValueKey: Hashable { + let space: CoordinateSpace + let id: String +} diff --git a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift new file mode 100644 index 000000000..650335a0e --- /dev/null +++ b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift @@ -0,0 +1,242 @@ +// +// SwiftUIView.swift +// +// +// Created by Eugene Yatsenko on 06/11/2023. +// + +import SwiftUI +import Theme + +public struct ScrollSlidingTabBar: View { + + @Binding private var selection: Int + @State private var buttonFrames: [Int: CGRect] = [:] + + private let tabs: [String] + private let style: Style + private let onTap: ((Int) -> Void)? + + private var containerSpace: String { + return "container" + } + + public init( + selection: Binding, + tabs: [String], + style: Style = .default, + onTap: ((Int) -> Void)? = nil) + { + self._selection = selection + self.tabs = tabs + self.style = style + self.onTap = onTap + } + + public var body: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + buttons() + + ZStack(alignment: .leading) { + Rectangle() + .fill(style.borderColor) + .frame(height: style.borderHeight, alignment: .leading) + indicatorContainer() + } + } + .coordinateSpace(name: containerSpace) + } + .onChange(of: selection) { newValue in + withAnimation { + proxy.scrollTo(newValue, anchor: .center) + } + } + } + } + +} + +extension ScrollSlidingTabBar { + private func buttons() -> some View { + HStack(spacing: 0) { + ForEach(Array(tabs.enumerated()), id: \.offset) { obj in + Button { + selection = obj.offset + onTap?(obj.offset) + } label: { + HStack { + Text(obj.element) + .font(isSelected(index: obj.offset) ? style.selectedFont : style.font) + } + .padding(.horizontal, style.buttonHInset) + .padding(.vertical, style.buttonVInset) + } + .accentColor( + isSelected(index: obj.offset) ? style.activeAccentColor : style.inactiveAccentColor + ) + .readFrame(in: .named(containerSpace)) { + buttonFrames[obj.offset] = $0 + } + .id(obj.offset) + } + } + } + + private func indicatorContainer() -> some View { + Rectangle() + .fill(Color.clear) + .frame(width: tabWidth(), height: style.indicatorHeight) + .overlay(indicator(), alignment: .center) + .offset(x: selectionBarXOffset(), y: 0) + .animation(.default, value: selection) + } + + private func indicator() -> some View { + Rectangle() + .fill(style.activeAccentColor) + .frame(width: indicatorWidth(selection: selection), height: style.indicatorHeight) + } +} + +extension ScrollSlidingTabBar { + private func sanitizedSelection() -> Int { + return max(0, min(tabs.count - 1, selection)) + } + + private func isSelected(index: Int) -> Bool { + return sanitizedSelection() == index + } + + private func selectionBarXOffset() -> CGFloat { + return buttonFrames[sanitizedSelection()]?.minX ?? .zero + } + + private func indicatorWidth(selection: Int) -> CGFloat { + return max(tabWidth() - style.buttonHInset * 2, .zero) + } + + private func tabWidth() -> CGFloat { + return buttonFrames[sanitizedSelection()]?.width ?? .zero + } +} + +extension ScrollSlidingTabBar { + public struct Style { + public let font: Font + public let selectedFont: Font + + public let activeAccentColor: Color + public let inactiveAccentColor: Color + + public let indicatorHeight: CGFloat + + public let borderColor: Color + public let borderHeight: CGFloat + + public let buttonHInset: CGFloat + public let buttonVInset: CGFloat + + public init( + font: Font, + selectedFont: Font, + activeAccentColor: Color, + inactiveAccentColor: Color, + indicatorHeight: CGFloat, + borderColor: Color, + borderHeight: CGFloat, + buttonHInset: CGFloat, + buttonVInset: CGFloat + ) { + self.font = font + self.selectedFont = selectedFont + self.activeAccentColor = activeAccentColor + self.inactiveAccentColor = inactiveAccentColor + self.indicatorHeight = indicatorHeight + self.borderColor = borderColor + self.borderHeight = borderHeight + self.buttonHInset = buttonHInset + self.buttonVInset = buttonVInset + } + + public static let `default` = Style( + font: .body, + selectedFont: .body.bold(), + activeAccentColor: Theme.Colors.accentColor , + inactiveAccentColor: .black.opacity(0.4), + indicatorHeight: 2, + borderColor: .gray.opacity(0.2), + borderHeight: 1, + buttonHInset: 16, + buttonVInset: 10 + ) + } +} + +#if DEBUG +private struct SlidingTabConsumerView: View { + @State + private var selection: Int = 0 + + var body: some View { + VStack(alignment: .leading) { + ScrollSlidingTabBar( + selection: $selection, + tabs: ["First", "Second", "Third", "Fourth", "Fifth", "Sixth"] + ) + TabView(selection: $selection) { + HStack { + Spacer() + Text("First View") + Spacer() + } + .tag(0) + + HStack { + Spacer() + Text("Second View") + Spacer() + } + .tag(1) + + HStack { + Spacer() + Text("Third View") + Spacer() + } + .tag(2) + + HStack { + Spacer() + Text("Fourth View") + Spacer() + } + .tag(3) + + HStack { + Spacer() + Text("Fifth View") + Spacer() + } + .tag(4) + + HStack { + Spacer() + Text("Sixth View") + Spacer() + } + .tag(5) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .animation(.default, value: selection) + } + } +} + +struct ScrollSlidingTabBar_Previews: PreviewProvider { + static var previews: some View { + SlidingTabConsumerView() + } +} +#endif diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 17101e38a..6b8606a94 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -13,17 +13,53 @@ import Theme public struct CourseContainerView: View { - enum CourseTab { + enum CourseTab: Int, CaseIterable, Identifiable { + var id: Int { + rawValue + } + case course case videos case dates case discussion case handounds + + var title: String { + switch self { + case .course: + return CourseLocalization.CourseContainer.course + case .videos: + return CourseLocalization.CourseContainer.videos + case .dates: + return CourseLocalization.CourseContainer.dates + case .discussion: + return CourseLocalization.CourseContainer.discussion + case .handounds: + return CourseLocalization.CourseContainer.handouts + } + } + + var image: Image { + switch self { + case .course: + return CoreAssets.bookCircle.swiftUIImage.renderingMode(.template) + case .videos: + return CoreAssets.videoCircle.swiftUIImage.renderingMode(.template) + case .dates: + return Image(systemName: "calendar").renderingMode(.template) + case .discussion: + return CoreAssets.bubbleLeftCircle.swiftUIImage.renderingMode(.template) + case .handounds: + return CoreAssets.docCircle.swiftUIImage.renderingMode(.template) + } + } + } @ObservedObject private var viewModel: CourseContainerViewModel - @State private var selection: CourseTab = .course + @State private var selection: Int = CourseTab.course.rawValue + @State private var isAnimatingForTap: Bool = false private var courseID: String private var title: String @@ -42,100 +78,153 @@ public struct CourseContainerView: View { public var body: some View { ZStack(alignment: .top) { - if let courseStart = viewModel.courseStart { - if courseStart > Date() { + content + } + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(titleBar()) + .onChange(of: selection, perform: didSelect) + } + + @ViewBuilder + private var content: some View { + if let courseStart = viewModel.courseStart { + if courseStart > Date() { + CourseOutlineView( + viewModel: viewModel, + title: title, + courseID: courseID, + isVideo: false + ) + } else { + VStack { + if viewModel.config.uiComponents.courseTopTabBarEnabled { + topTabBar + } + tabs + } + } + } + } + + private var topTabBar: some View { + ScrollSlidingTabBar( + selection: $selection, + tabs: CourseTab.allCases.map { $0.title } + ) { newValue in + isAnimatingForTap = true + selection = newValue + DispatchQueue.main.asyncAfter(deadline: .now().advanced(by: .milliseconds(300))) { + isAnimatingForTap = false + } + } + } + + private var tabs: some View { + TabView(selection: $selection) { + ForEach(CourseTab.allCases) { tab in + switch tab { + case .course: CourseOutlineView( viewModel: viewModel, title: title, courseID: courseID, isVideo: false ) - } else { - TabView(selection: $selection) { - CourseOutlineView( - viewModel: self.viewModel, - title: title, - courseID: courseID, - isVideo: false - ).accessibilityAction {} - .tabItem { - CoreAssets.bookCircle.swiftUIImage.renderingMode(.template) - Text(CourseLocalization.CourseContainer.course) - } - .tag(CourseTab.course) - - CourseOutlineView( - viewModel: self.viewModel, - title: title, - courseID: courseID, - isVideo: true - ).accessibilityAction {} - .tabItem { - CoreAssets.videoCircle.swiftUIImage.renderingMode(.template) - Text(CourseLocalization.CourseContainer.videos) - } - .tag(CourseTab.videos) - - CourseDatesView(courseID: courseID, - viewModel: Container.shared.resolve(CourseDatesViewModel.self, - argument: courseID)!) - .tabItem { - Image(systemName: "calendar").renderingMode(.template) - Text(CourseLocalization.CourseContainer.dates) - } - .tag(CourseTab.dates) - - DiscussionTopicsView(courseID: courseID, - viewModel: Container.shared.resolve(DiscussionTopicsViewModel.self, - argument: title)!, - router: Container.shared.resolve(DiscussionRouter.self)!) - .tabItem { - CoreAssets.bubbleLeftCircle.swiftUIImage.renderingMode(.template) - Text(CourseLocalization.CourseContainer.discussion) - } - .tag(CourseTab.discussion) - - HandoutsView(courseID: courseID, - viewModel: Container.shared.resolve(HandoutsViewModel.self, argument: courseID)!) - .tabItem { - CoreAssets.docCircle.swiftUIImage.renderingMode(.template) - Text(CourseLocalization.CourseContainer.handouts) - } - .tag(CourseTab.handounds) + .tabItem { + tab.image + Text(tab.title) + } + .tag(tab) + .accentColor(Theme.Colors.accentColor) + case .videos: + CourseOutlineView( + viewModel: viewModel, + title: title, + courseID: courseID, + isVideo: true + ) + .tabItem { + tab.image + Text(tab.title) } - .onFirstAppear { - Task { - await viewModel.tryToRefreshCookies() - } + .tag(tab) + .accentColor(Theme.Colors.accentColor) + case .dates: + CourseDatesView( + courseID: courseID, + viewModel: Container.shared.resolve(CourseDatesViewModel.self, + argument: courseID)! + ) + .tabItem { + tab.image + Text(tab.title) } + .tag(tab) + .accentColor(Theme.Colors.accentColor) + case .discussion: + DiscussionTopicsView( + courseID: courseID, + viewModel: Container.shared.resolve(DiscussionTopicsViewModel.self, + argument: title)!, + router: Container.shared.resolve(DiscussionRouter.self)! + ) + .tabItem { + tab.image + Text(tab.title) + } + .tag(tab) + .accentColor(Theme.Colors.accentColor) + case .handounds: + HandoutsView( + courseID: courseID, + viewModel: Container.shared.resolve(HandoutsViewModel.self, argument: courseID)! + ) + .tabItem { + tab.image + Text(tab.title) + } + .tag(tab) .accentColor(Theme.Colors.accentColor) } } } - .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) - .navigationTitle(titleBar()) - .onChange(of: selection, perform: { selection in + .if(viewModel.config.uiComponents.courseTopTabBarEnabled) { view in + view + .tabViewStyle(.page(indexDisplayMode: .never)) + .animation(.default, value: selection) + } + .onFirstAppear { + Task { + await viewModel.tryToRefreshCookies() + } + } + } + + private func didSelect(_ selection: Int) { + CourseTab(rawValue: selection).flatMap { viewModel.trackSelectedTab( - selection: selection, + selection: $0, courseId: courseID, courseName: title ) - }) + } } - + private func titleBar() -> String { - switch selection { + switch CourseTab(rawValue: selection) { case .course: return self.title case .videos: return self.title + case .dates: + return CourseLocalization.CourseContainer.dates case .discussion: return DiscussionLocalization.title case .handounds: return CourseLocalization.CourseContainer.handouts - case .dates: - return CourseLocalization.CourseContainer.dates + default: + return "" } } }