From cbd1a53425da14ba90547e756091f4e9bd3cd551 Mon Sep 17 00:00:00 2001 From: Eugene Yatsenko Date: Mon, 6 Nov 2023 11:51:47 +0100 Subject: [PATCH 1/8] chore: add slide tab bars --- Core/Core.xcodeproj/project.pbxproj | 20 ++ .../ScrollSlidingTabBar/FrameReader.swift | 41 +++ .../ScrollSlidingTabBar.swift | 233 +++++++++++++++ .../ScrollSlidingTabBar/SlidingTabBar.swift | 280 ++++++++++++++++++ .../Container/CourseContainerView.swift | 139 ++++++--- Podfile.lock | 2 +- 6 files changed, 669 insertions(+), 46 deletions(-) create mode 100644 Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift create mode 100644 Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift create mode 100644 Core/Core/View/Base/ScrollSlidingTabBar/SlidingTabBar.swift diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index eed739e2e..2e982faa5 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -112,6 +112,9 @@ 0770DE7928D0C4A9006D8A5D /* RoundedCorners.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE7828D0C4A9006D8A5D /* RoundedCorners.swift */; }; 0770DE7B28D0C78C006D8A5D /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE7A28D0C78C006D8A5D /* Theme.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 */; }; + BA593F202AF8E4A6009ADB51 /* SlidingTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA593F1F2AF8E4A6009ADB51 /* SlidingTabBar.swift */; }; C8C446EF233F81B9FABB77D2 /* Pods_App_Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 349B90CD6579F7B8D257E515 /* Pods_App_Core.framework */; }; CFC84952299F8B890055E497 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFC84951299F8B890055E497 /* Debounce.swift */; }; /* End PBXBuildFile section */ @@ -241,6 +244,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 = ""; }; + BA593F1F2AF8E4A6009ADB51 /* SlidingTabBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SlidingTabBar.swift; sourceTree = ""; }; C7E5BCE79CE297B20777B27A /* Pods-App-Core.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugprod.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugprod.xcconfig"; sourceTree = ""; }; CFC84951299F8B890055E497 /* Debounce.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debounce.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -509,6 +515,7 @@ 0770DE7728D0C49E006D8A5D /* Base */ = { isa = PBXGroup; children = ( + BA593F1A2AF8E487009ADB51 /* ScrollSlidingTabBar */, 0770DE7828D0C4A9006D8A5D /* RoundedCorners.swift */, 02A4833B29B8C57800D33F33 /* DownloadView.swift */, 02D800CB29348F460099CF16 /* ImagePicker.swift */, @@ -540,6 +547,16 @@ path = Base; sourceTree = ""; }; + BA593F1A2AF8E487009ADB51 /* ScrollSlidingTabBar */ = { + isa = PBXGroup; + children = ( + BA593F1F2AF8E4A6009ADB51 /* SlidingTabBar.swift */, + BA593F1D2AF8E4A0009ADB51 /* FrameReader.swift */, + BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */, + ); + path = ScrollSlidingTabBar; + sourceTree = ""; + }; C9DFE47E699CFFA85A77AF2C /* Pods */ = { isa = PBXGroup; children = ( @@ -767,7 +784,9 @@ 0282DA7328F98CC9003C3F07 /* WebUnitView.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 */, @@ -839,6 +858,7 @@ 02C2DC0829B63D6200F4445D /* WebViewHTML.swift in Sources */, 025B36752A13B7D5001A640E /* UnitButtonView.swift in Sources */, 028F9F39293A452B00DE65D0 /* ResetPassword.swift in Sources */, + BA593F202AF8E4A6009ADB51 /* SlidingTabBar.swift in Sources */, 027BD3B82909476200392132 /* DismissKeyboardTapViewModifier.swift in Sources */, 024BE3DF29B2615500BCDEE2 /* CGColorExtension.swift in Sources */, 0770DE6128D0B2CB006D8A5D /* Assets.swift in Sources */, diff --git a/Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift b/Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift new file mode 100644 index 000000000..982c06d47 --- /dev/null +++ b/Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift @@ -0,0 +1,41 @@ +// +// File.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..1860f684a --- /dev/null +++ b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift @@ -0,0 +1,233 @@ +// +// SwiftUIView.swift +// +// +// Created by Eugene Yatsenko on 06/11/2023. +// + +import SwiftUI + +public struct ScrollSlidingTabBar: View { + @Binding + private var selection: Int + + private let tabs: [String] + + private let style: Style + + private let onTap: ((Int) -> Void)? + + @State + private var buttonFrames: [Int: CGRect] = [:] + + 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: .blue, + 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/Core/Core/View/Base/ScrollSlidingTabBar/SlidingTabBar.swift b/Core/Core/View/Base/ScrollSlidingTabBar/SlidingTabBar.swift new file mode 100644 index 000000000..99c6dbe0a --- /dev/null +++ b/Core/Core/View/Base/ScrollSlidingTabBar/SlidingTabBar.swift @@ -0,0 +1,280 @@ +// +// SwiftUIView.swift +// +// +// Created by Eugene Yatsenko on 06/11/2023. +// + +import SwiftUI + +public struct SlidingTabBar: View { + @Binding + private var selection: CGFloat + + private let tabs: [String] + + private let style: Style + + @State + private var size: CGSize? + + @State + private var labelWidths: [Int: CGFloat] = [:] + + private let onTap: ((Int) -> Void)? + + 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 init(selection: Binding, + tabs: [String], + style: Style = .default, + onTap: ((Int) -> Void)? = nil) { + self._selection = .init(get: { + CGFloat(selection.wrappedValue) + }, set: { newValue in + selection.wrappedValue = Int(newValue.rounded()) + }) + self.tabs = tabs + self.style = style + self.onTap = onTap + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 0) { + ForEach(Array(tabs.enumerated()), id: \.offset) { obj in + Button { + selection = CGFloat(obj.offset) + onTap?(obj.offset) + } label: { + HStack { + Spacer() + Text(obj.element) + .font(isSelected(index: obj.offset) ? style.selectedFont : style.font) + .readFrame(in: .local, id: "button") { + if labelWidths[obj.offset] != $0.width { + labelWidths[obj.offset] = $0.width + } + } + Spacer() + } + .padding(.vertical, style.buttonVInset) + } + .accentColor( + isSelected(index: obj.offset) ? style.activeAccentColor : style.inactiveAccentColor + ) + } + } + + ZStack(alignment: .leading) { + Rectangle() + .fill(style.borderColor) + .frame(width: totalWidth(), height: style.borderHeight, alignment: .leading) + indicatorContainer() + } + } + .readFrame(in: .local) { + if size != $0.size { + size = $0.size + } + } + } +} + +extension SlidingTabBar { + 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 SlidingTabBar { + private func sanitizedSelection() -> CGFloat { + return max(0, min(CGFloat(tabs.count - 1), selection)) + } + + private func isSelected(index: Int) -> Bool { + return Int(sanitizedSelection().rounded()) == index + } + + private func selectionBarXOffset() -> CGFloat { + return tabWidth() * sanitizedSelection() + } + + private func indicatorWidth(selection: CGFloat) -> CGFloat { + let leftIndex = max(0, Int(selection.rounded(.down))) + let rightIndex = min(tabs.count - 1, Int(selection.rounded(.up))) + + guard let leftWidth = labelWidths[leftIndex], let rightWidth = labelWidths[rightIndex] else { + return tabWidth() + } + guard leftIndex < rightIndex else { + return leftWidth + } + + let progress = selection - CGFloat(leftIndex) + return leftWidth * (1 - progress) + rightWidth * progress + } + + private func totalWidth() -> CGFloat { + return size?.width ?? .zero + } + + private func tabWidth() -> CGFloat { + return totalWidth() / CGFloat(tabs.count) + } +} + +extension SlidingTabBar { + 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 buttonVInset: CGFloat + + public init(font: Font, selectedFont: Font, activeAccentColor: Color, inactiveAccentColor: Color, indicatorHeight: CGFloat, borderColor: Color, borderHeight: 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.buttonVInset = buttonVInset + } + + public static let `default` = Style( + font: .system(size: 14, weight: .medium), + selectedFont: .system(size: 14, weight: .bold), + activeAccentColor: .blue, + inactiveAccentColor: .black.opacity(0.4), + indicatorHeight: 2, + borderColor: .gray.opacity(0.2), + borderHeight: 1, + buttonVInset: 10 + ) + } +} + +#if DEBUG +// Progressive selection +@available(iOS 14.0, *) +private struct SlidingTabProgressiveSelectionView: View { + @State + private var tabBarSelection: CGFloat = 0 + + @State + private var tabViewSelection: Int = 0 + + @State + private var isAnimatingForTap: Bool = false + + private var cooridnateSpaceName: String { + return "scrollview" + } + + var body: some View { + VStack(alignment: .leading) { + SlidingTabBar( + selection: $tabBarSelection, + tabs: ["First", "Second"] + ) { newValue in + isAnimatingForTap = true + tabViewSelection = newValue + DispatchQueue.main.asyncAfter(deadline: .now().advanced(by: .milliseconds(300))) { + isAnimatingForTap = false + } + } + TabView(selection: $tabViewSelection) { + HStack { + Spacer() + Text("First View") + Spacer() + } + .tag(0) + .readFrame(in: .named(cooridnateSpaceName)) { frame in + guard !isAnimatingForTap else { return } + tabBarSelection = (-frame.origin.x / frame.width) + } + + HStack { + Spacer() + Text("Second View") + Spacer() + } + .tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .coordinateSpace(name: cooridnateSpaceName) + .animation(.linear(duration: 0.2), value: tabViewSelection) + } + } +} + +// Non-progressive selection +@available(iOS 14.0, *) +private struct SlidingTabSelectionView: View { + @State + private var selection: Int = 0 + + var body: some View { + VStack(alignment: .leading) { + SlidingTabBar( + selection: $selection, + tabs: ["First", "Second"] + ) + TabView(selection: $selection) { + HStack { + Spacer() + Text("First View") + Spacer() + } + .tag(0) + + HStack { + Spacer() + Text("Second View") + Spacer() + } + .tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .animation(.default, value: selection) + } + } +} + +@available(iOS 14.0, *) +struct SlidingTabBar_Previews: PreviewProvider { + static var previews: some View { + SlidingTabProgressiveSelectionView() + SlidingTabSelectionView() + } +} +#endif diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 574f71b81..8ba2bb996 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -12,16 +12,49 @@ import Swinject public struct CourseContainerView: View { - enum CourseTab { + enum CourseTab: Int, CaseIterable, Identifiable { + var id: Int { + rawValue + } + case course case videos case discussion case handounds + + var title: String { + switch self { + case .course: + return CourseLocalization.CourseContainer.course + case .videos: + return CourseLocalization.CourseContainer.videos + 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 .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 selectionTab: Int = CourseTab.course.rawValue + @State private var isAnimatingForTap: Bool = false private var courseID: String private var title: String @@ -49,52 +82,67 @@ public struct CourseContainerView: View { isVideo: false ) } else { - TabView(selection: $selection) { - CourseOutlineView( - viewModel: self.viewModel, - title: title, - courseID: courseID, - isVideo: false - ) - .tabItem { - CoreAssets.bookCircle.swiftUIImage.renderingMode(.template) - Text(CourseLocalization.CourseContainer.course) - } - .tag(CourseTab.course) - - CourseOutlineView( - viewModel: self.viewModel, - title: title, - courseID: courseID, - isVideo: true - ) - .tabItem { - CoreAssets.videoCircle.swiftUIImage.renderingMode(.template) - Text(CourseLocalization.CourseContainer.videos) + VStack { + SlidingTabBar( + selection: $selectionTab, + tabs: CourseTab.allCases.map { $0.title } + ) { newValue in + isAnimatingForTap = true + selection = .init(rawValue: newValue) ?? .course + selectionTab = newValue + DispatchQueue.main.asyncAfter(deadline: .now().advanced(by: .milliseconds(300))) { + isAnimatingForTap = false + } } - .tag(CourseTab.videos) - - 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) + TabView(selection: $selection) { + CourseOutlineView( + viewModel: self.viewModel, + title: title, + courseID: courseID, + isVideo: false + ) + .tabItem { + CoreAssets.bookCircle.swiftUIImage.renderingMode(.template) + Text(CourseLocalization.CourseContainer.course) + } + .tag(CourseTab.course) + + CourseOutlineView( + viewModel: self.viewModel, + title: title, + courseID: courseID, + isVideo: true + ) + .tabItem { + CoreAssets.videoCircle.swiftUIImage.renderingMode(.template) + Text(CourseLocalization.CourseContainer.videos) + } + .tag(CourseTab.videos) + + 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) } - .tag(CourseTab.handounds) - } - .onFirstAppear { - Task { - await viewModel.tryToRefreshCookies() + .tabViewStyle(.page(indexDisplayMode: .never)) + .animation(.default, value: selection) + .onFirstAppear { + Task { + await viewModel.tryToRefreshCookies() + } } } } @@ -104,6 +152,7 @@ public struct CourseContainerView: View { .navigationBarBackButtonHidden(false) .navigationTitle(titleBar()) .onChange(of: selection, perform: { selection in + self.selectionTab = selection.rawValue viewModel.trackSelectedTab( selection: selection, courseId: courseID, diff --git a/Podfile.lock b/Podfile.lock index 5b998cd5e..dc0017b13 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -182,4 +182,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 1639b311802f5d36686512914067b7221ff97a64 -COCOAPODS: 1.12.1 +COCOAPODS: 1.13.0 From c2081ec6ca6749a91fbc5efc1151717722590467 Mon Sep 17 00:00:00 2001 From: Eugene Yatsenko Date: Tue, 7 Nov 2023 12:37:10 +0100 Subject: [PATCH 2/8] refactor: separate views --- .../Container/CourseContainerView.swift | 165 +++++++++--------- 1 file changed, 87 insertions(+), 78 deletions(-) diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 8ba2bb996..a37ee54ec 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -52,8 +52,7 @@ public struct CourseContainerView: View { @ObservedObject private var viewModel: CourseContainerViewModel - @State private var selection: CourseTab = .course - @State private var selectionTab: Int = CourseTab.course.rawValue + @State private var selection: Int = CourseTab.course.rawValue @State private var isAnimatingForTap: Bool = false private var courseID: String private var title: String @@ -73,96 +72,104 @@ 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 { + topTabBar + tabs + } + } + } + } + + private var topTabBar: some View { + SlidingTabBar( + 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, + viewModel: self.viewModel, title: title, courseID: courseID, isVideo: false ) - } else { - VStack { - SlidingTabBar( - selection: $selectionTab, - tabs: CourseTab.allCases.map { $0.title } - ) { newValue in - isAnimatingForTap = true - selection = .init(rawValue: newValue) ?? .course - selectionTab = newValue - DispatchQueue.main.asyncAfter(deadline: .now().advanced(by: .milliseconds(300))) { - isAnimatingForTap = false - } - } - TabView(selection: $selection) { - CourseOutlineView( - viewModel: self.viewModel, - title: title, - courseID: courseID, - isVideo: false - ) - .tabItem { - CoreAssets.bookCircle.swiftUIImage.renderingMode(.template) - Text(CourseLocalization.CourseContainer.course) - } - .tag(CourseTab.course) - - CourseOutlineView( - viewModel: self.viewModel, - title: title, - courseID: courseID, - isVideo: true - ) - .tabItem { - CoreAssets.videoCircle.swiftUIImage.renderingMode(.template) - Text(CourseLocalization.CourseContainer.videos) - } - .tag(CourseTab.videos) - - 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) - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .animation(.default, value: selection) - .onFirstAppear { - Task { - await viewModel.tryToRefreshCookies() - } - } - } + .tag(tab) + case .videos: + CourseOutlineView( + viewModel: self.viewModel, + title: title, + courseID: courseID, + isVideo: true + ) + .tag(tab) + case .discussion: + DiscussionTopicsView( + courseID: courseID, + viewModel: Container.shared.resolve(DiscussionTopicsViewModel.self, + argument: title)!, + router: Container.shared.resolve(DiscussionRouter.self)! + ) + .tag(tab) + case .handounds: + HandoutsView( + courseID: courseID, + viewModel: Container.shared.resolve(HandoutsViewModel.self, argument: courseID)! + ) + .tag(tab) } } } - .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) - .navigationTitle(titleBar()) - .onChange(of: selection, perform: { selection in - self.selectionTab = selection.rawValue + .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: @@ -171,6 +178,8 @@ public struct CourseContainerView: View { return DiscussionLocalization.title case .handounds: return CourseLocalization.CourseContainer.handouts + default: + return "" } } } From d080317d17af70c474fcca867deac4cadfff680f Mon Sep 17 00:00:00 2001 From: Eugene Yatsenko Date: Wed, 29 Nov 2023 09:46:20 +0100 Subject: [PATCH 3/8] chore: remove extra files --- Core/Core.xcodeproj/project.pbxproj | 22 +- .../ScrollSlidingTabBar/FrameReader.swift | 6 +- .../ScrollSlidingTabBar.swift | 34 ++- .../ScrollSlidingTabBar/SlidingTabBar.swift | 280 ------------------ 4 files changed, 35 insertions(+), 307 deletions(-) delete mode 100644 Core/Core/View/Base/ScrollSlidingTabBar/SlidingTabBar.swift diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index a791953cc..cee43dc24 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -122,7 +122,6 @@ 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 */; }; - BA593F202AF8E4A6009ADB51 /* SlidingTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA593F1F2AF8E4A6009ADB51 /* SlidingTabBar.swift */; }; C8C446EF233F81B9FABB77D2 /* Pods_App_Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 349B90CD6579F7B8D257E515 /* Pods_App_Core.framework */; }; CFC84952299F8B890055E497 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFC84951299F8B890055E497 /* Debounce.swift */; }; DBF6F2412B014ADA0098414B /* FirebaseConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */; }; @@ -266,7 +265,6 @@ 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 = ""; }; - BA593F1F2AF8E4A6009ADB51 /* SlidingTabBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SlidingTabBar.swift; sourceTree = ""; }; C7E5BCE79CE297B20777B27A /* Pods-App-Core.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugprod.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugprod.xcconfig"; sourceTree = ""; }; CFC84951299F8B890055E497 /* Debounce.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debounce.swift; sourceTree = ""; }; DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseConfig.swift; sourceTree = ""; }; @@ -604,16 +602,6 @@ path = Base; sourceTree = ""; }; - BA593F1A2AF8E487009ADB51 /* ScrollSlidingTabBar */ = { - isa = PBXGroup; - children = ( - BA593F1F2AF8E4A6009ADB51 /* SlidingTabBar.swift */, - BA593F1D2AF8E4A0009ADB51 /* FrameReader.swift */, - BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */, - ); - path = ScrollSlidingTabBar; - sourceTree = ""; - }; 078525AC2B0CBFF4007B4521 /* CoreTests */ = { isa = PBXGroup; children = ( @@ -630,6 +618,15 @@ path = Configuration; sourceTree = ""; }; + BA593F1A2AF8E487009ADB51 /* ScrollSlidingTabBar */ = { + isa = PBXGroup; + children = ( + BA593F1D2AF8E4A0009ADB51 /* FrameReader.swift */, + BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */, + ); + path = ScrollSlidingTabBar; + sourceTree = ""; + }; C9DFE47E699CFFA85A77AF2C /* Pods */ = { isa = PBXGroup; children = ( @@ -954,7 +951,6 @@ 02A463112AEA966C00331037 /* AppReviewView.swift in Sources */, 025B36752A13B7D5001A640E /* UnitButtonView.swift in Sources */, 028F9F39293A452B00DE65D0 /* ResetPassword.swift in Sources */, - BA593F202AF8E4A6009ADB51 /* SlidingTabBar.swift in Sources */, 0233D56F2AF13EB200BAC8BD /* StarRatingView.swift in Sources */, 027BD3B82909476200392132 /* DismissKeyboardTapViewModifier.swift in Sources */, 024BE3DF29B2615500BCDEE2 /* CGColorExtension.swift in Sources */, diff --git a/Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift b/Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift index 982c06d47..690b880a2 100644 --- a/Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift +++ b/Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift @@ -11,7 +11,11 @@ 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 { + public func readFrame( + in space: CoordinateSpace, + id: String = "shared", + onChange: @escaping (CGRect) -> Void + ) -> some View { background( GeometryReader { proxy in Color diff --git a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift index 1f21407e0..aef4cca4b 100644 --- a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift +++ b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift @@ -8,26 +8,24 @@ import SwiftUI public struct ScrollSlidingTabBar: View { - @Binding - private var selection: Int + + @Binding private var selection: Int + @State private var buttonFrames: [Int: CGRect] = [:] private let tabs: [String] - private let style: Style - private let onTap: ((Int) -> Void)? - - @State - private var buttonFrames: [Int: CGRect] = [:] - + private var containerSpace: String { return "container" } - public init(selection: Binding, - tabs: [String], - style: Style = .default, - onTap: ((Int) -> Void)? = nil) { + public init( + selection: Binding, + tabs: [String], + style: Style = .default, + onTap: ((Int) -> Void)? = nil) + { self._selection = selection self.tabs = tabs self.style = style @@ -139,7 +137,17 @@ extension ScrollSlidingTabBar { 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) { + 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 diff --git a/Core/Core/View/Base/ScrollSlidingTabBar/SlidingTabBar.swift b/Core/Core/View/Base/ScrollSlidingTabBar/SlidingTabBar.swift deleted file mode 100644 index 99c6dbe0a..000000000 --- a/Core/Core/View/Base/ScrollSlidingTabBar/SlidingTabBar.swift +++ /dev/null @@ -1,280 +0,0 @@ -// -// SwiftUIView.swift -// -// -// Created by Eugene Yatsenko on 06/11/2023. -// - -import SwiftUI - -public struct SlidingTabBar: View { - @Binding - private var selection: CGFloat - - private let tabs: [String] - - private let style: Style - - @State - private var size: CGSize? - - @State - private var labelWidths: [Int: CGFloat] = [:] - - private let onTap: ((Int) -> Void)? - - 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 init(selection: Binding, - tabs: [String], - style: Style = .default, - onTap: ((Int) -> Void)? = nil) { - self._selection = .init(get: { - CGFloat(selection.wrappedValue) - }, set: { newValue in - selection.wrappedValue = Int(newValue.rounded()) - }) - self.tabs = tabs - self.style = style - self.onTap = onTap - } - - public var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 0) { - ForEach(Array(tabs.enumerated()), id: \.offset) { obj in - Button { - selection = CGFloat(obj.offset) - onTap?(obj.offset) - } label: { - HStack { - Spacer() - Text(obj.element) - .font(isSelected(index: obj.offset) ? style.selectedFont : style.font) - .readFrame(in: .local, id: "button") { - if labelWidths[obj.offset] != $0.width { - labelWidths[obj.offset] = $0.width - } - } - Spacer() - } - .padding(.vertical, style.buttonVInset) - } - .accentColor( - isSelected(index: obj.offset) ? style.activeAccentColor : style.inactiveAccentColor - ) - } - } - - ZStack(alignment: .leading) { - Rectangle() - .fill(style.borderColor) - .frame(width: totalWidth(), height: style.borderHeight, alignment: .leading) - indicatorContainer() - } - } - .readFrame(in: .local) { - if size != $0.size { - size = $0.size - } - } - } -} - -extension SlidingTabBar { - 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 SlidingTabBar { - private func sanitizedSelection() -> CGFloat { - return max(0, min(CGFloat(tabs.count - 1), selection)) - } - - private func isSelected(index: Int) -> Bool { - return Int(sanitizedSelection().rounded()) == index - } - - private func selectionBarXOffset() -> CGFloat { - return tabWidth() * sanitizedSelection() - } - - private func indicatorWidth(selection: CGFloat) -> CGFloat { - let leftIndex = max(0, Int(selection.rounded(.down))) - let rightIndex = min(tabs.count - 1, Int(selection.rounded(.up))) - - guard let leftWidth = labelWidths[leftIndex], let rightWidth = labelWidths[rightIndex] else { - return tabWidth() - } - guard leftIndex < rightIndex else { - return leftWidth - } - - let progress = selection - CGFloat(leftIndex) - return leftWidth * (1 - progress) + rightWidth * progress - } - - private func totalWidth() -> CGFloat { - return size?.width ?? .zero - } - - private func tabWidth() -> CGFloat { - return totalWidth() / CGFloat(tabs.count) - } -} - -extension SlidingTabBar { - 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 buttonVInset: CGFloat - - public init(font: Font, selectedFont: Font, activeAccentColor: Color, inactiveAccentColor: Color, indicatorHeight: CGFloat, borderColor: Color, borderHeight: 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.buttonVInset = buttonVInset - } - - public static let `default` = Style( - font: .system(size: 14, weight: .medium), - selectedFont: .system(size: 14, weight: .bold), - activeAccentColor: .blue, - inactiveAccentColor: .black.opacity(0.4), - indicatorHeight: 2, - borderColor: .gray.opacity(0.2), - borderHeight: 1, - buttonVInset: 10 - ) - } -} - -#if DEBUG -// Progressive selection -@available(iOS 14.0, *) -private struct SlidingTabProgressiveSelectionView: View { - @State - private var tabBarSelection: CGFloat = 0 - - @State - private var tabViewSelection: Int = 0 - - @State - private var isAnimatingForTap: Bool = false - - private var cooridnateSpaceName: String { - return "scrollview" - } - - var body: some View { - VStack(alignment: .leading) { - SlidingTabBar( - selection: $tabBarSelection, - tabs: ["First", "Second"] - ) { newValue in - isAnimatingForTap = true - tabViewSelection = newValue - DispatchQueue.main.asyncAfter(deadline: .now().advanced(by: .milliseconds(300))) { - isAnimatingForTap = false - } - } - TabView(selection: $tabViewSelection) { - HStack { - Spacer() - Text("First View") - Spacer() - } - .tag(0) - .readFrame(in: .named(cooridnateSpaceName)) { frame in - guard !isAnimatingForTap else { return } - tabBarSelection = (-frame.origin.x / frame.width) - } - - HStack { - Spacer() - Text("Second View") - Spacer() - } - .tag(1) - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .coordinateSpace(name: cooridnateSpaceName) - .animation(.linear(duration: 0.2), value: tabViewSelection) - } - } -} - -// Non-progressive selection -@available(iOS 14.0, *) -private struct SlidingTabSelectionView: View { - @State - private var selection: Int = 0 - - var body: some View { - VStack(alignment: .leading) { - SlidingTabBar( - selection: $selection, - tabs: ["First", "Second"] - ) - TabView(selection: $selection) { - HStack { - Spacer() - Text("First View") - Spacer() - } - .tag(0) - - HStack { - Spacer() - Text("Second View") - Spacer() - } - .tag(1) - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .animation(.default, value: selection) - } - } -} - -@available(iOS 14.0, *) -struct SlidingTabBar_Previews: PreviewProvider { - static var previews: some View { - SlidingTabProgressiveSelectionView() - SlidingTabSelectionView() - } -} -#endif From c34ab6d7907d4a858bbe05f5b6d24d1758ee0938 Mon Sep 17 00:00:00 2001 From: Eugene Yatsenko Date: Wed, 29 Nov 2023 12:04:45 +0100 Subject: [PATCH 4/8] chore: add file name to top FrameReader --- Core/Core.xcodeproj/project.pbxproj | 2 +- Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index cee43dc24..f17d7acb6 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -569,7 +569,6 @@ 0770DE7728D0C49E006D8A5D /* Base */ = { isa = PBXGroup; children = ( - BA593F1A2AF8E487009ADB51 /* ScrollSlidingTabBar */, 0770DE7828D0C4A9006D8A5D /* RoundedCorners.swift */, 02A4833B29B8C57800D33F33 /* DownloadView.swift */, 02D800CB29348F460099CF16 /* ImagePicker.swift */, @@ -597,6 +596,7 @@ 027BD3C42909707700392132 /* Shake.swift */, 023A1135291432B200D0D354 /* RegistrationTextField.swift */, 023A1137291432FD00D0D354 /* FieldConfiguration.swift */, + BA593F1A2AF8E487009ADB51 /* ScrollSlidingTabBar */, 02E93F862AEBAED4006C4750 /* AppReview */, ); path = Base; diff --git a/Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift b/Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift index 690b880a2..5cc0ff18f 100644 --- a/Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift +++ b/Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift @@ -1,5 +1,5 @@ // -// File.swift +// FrameReader.swift // // // Created by Eugene Yatsenko on 06/11/2023. From 66db3e9d6fb79acc3a5fe235437c549fa1c4ef51 Mon Sep 17 00:00:00 2001 From: Eugene Yatsenko Date: Wed, 29 Nov 2023 12:07:19 +0100 Subject: [PATCH 5/8] chore: remove extra code --- .../Course/Presentation/Container/CourseContainerView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 2720f01bd..9a3237107 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -123,7 +123,7 @@ public struct CourseContainerView: View { switch tab { case .course: CourseOutlineView( - viewModel: self.viewModel, + viewModel: viewModel, title: title, courseID: courseID, isVideo: false @@ -131,7 +131,7 @@ public struct CourseContainerView: View { .tag(tab) case .videos: CourseOutlineView( - viewModel: self.viewModel, + viewModel: viewModel, title: title, courseID: courseID, isVideo: true From 7ddc6edab889e1cea9dabd4bcc93fba68547575e Mon Sep 17 00:00:00 2001 From: Eugene Yatsenko Date: Thu, 7 Dec 2023 12:18:15 +0100 Subject: [PATCH 6/8] chore: add course top tab bar enabled flag --- .../Configuration/Config/FeaturesConfig.swift | 5 ++- Core/Core/Extensions/ViewExtension.swift | 15 +++++++++ .../Container/CourseContainerView.swift | 31 +++++++++++++++++-- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/Core/Core/Configuration/Config/FeaturesConfig.swift b/Core/Core/Configuration/Config/FeaturesConfig.swift index eb6c6227f..0f01d597c 100644 --- a/Core/Core/Configuration/Config/FeaturesConfig.swift +++ b/Core/Core/Configuration/Config/FeaturesConfig.swift @@ -9,13 +9,16 @@ import Foundation private enum FeaturesKeys: String { case whatNewEnabled = "WHATS_NEW_ENABLED" + case courseTopTabBarEnabled = "COURSE_TOP_TABBAR_ENABLED" } public class FeaturesConfig: NSObject { public var whatNewEnabled: Bool - + public var courseTopTabBarEnabled: Bool + init(dictionary: [String: Any]) { whatNewEnabled = dictionary[FeaturesKeys.whatNewEnabled.rawValue] as? Bool ?? false + courseTopTabBarEnabled = dictionary[FeaturesKeys.courseTopTabBarEnabled.rawValue] as? Bool ?? false super.init() } } diff --git a/Core/Core/Extensions/ViewExtension.swift b/Core/Core/Extensions/ViewExtension.swift index 8ccc2ae6b..8374ceea1 100644 --- a/Core/Core/Extensions/ViewExtension.swift +++ b/Core/Core/Extensions/ViewExtension.swift @@ -239,6 +239,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/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 9a3237107..90a56e0d4 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -97,7 +97,9 @@ public struct CourseContainerView: View { ) } else { VStack { - topTabBar + if viewModel.config.features.courseTopTabBarEnabled { + topTabBar + } tabs } } @@ -128,6 +130,10 @@ public struct CourseContainerView: View { courseID: courseID, isVideo: false ) + .tabItem { + tab.image + Text(tab.title) + } .tag(tab) case .videos: CourseOutlineView( @@ -136,6 +142,10 @@ public struct CourseContainerView: View { courseID: courseID, isVideo: true ) + .tabItem { + tab.image + Text(tab.title) + } .tag(tab) case .dates: CourseDatesView( @@ -143,6 +153,10 @@ public struct CourseContainerView: View { viewModel: Container.shared.resolve(CourseDatesViewModel.self, argument: courseID)! ) + .tabItem { + tab.image + Text(tab.title) + } .tag(tab) case .discussion: DiscussionTopicsView( @@ -151,18 +165,29 @@ public struct CourseContainerView: View { argument: title)!, router: Container.shared.resolve(DiscussionRouter.self)! ) + .tabItem { + tab.image + Text(tab.title) + } .tag(tab) case .handounds: HandoutsView( courseID: courseID, viewModel: Container.shared.resolve(HandoutsViewModel.self, argument: courseID)! ) + .tabItem { + tab.image + Text(tab.title) + } .tag(tab) } } } - .tabViewStyle(.page(indexDisplayMode: .never)) - .animation(.default, value: selection) + .if(viewModel.config.features.courseTopTabBarEnabled) { view in + view + .tabViewStyle(.page(indexDisplayMode: .never)) + .animation(.default, value: selection) + } .onFirstAppear { Task { await viewModel.tryToRefreshCookies() From 06bc8a229c37106250cf82f264915e181595332f Mon Sep 17 00:00:00 2001 From: Eugene Yatsenko Date: Mon, 11 Dec 2023 18:11:53 +0100 Subject: [PATCH 7/8] chore: add ui flags to default config --- Core/Core/Configuration/Config/UIComponentsConfig.swift | 2 +- default_config/dev/config.yaml | 6 ++++++ default_config/prod/config.yaml | 6 ++++++ default_config/stage/config.yaml | 6 ++++++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Core/Core/Configuration/Config/UIComponentsConfig.swift b/Core/Core/Configuration/Config/UIComponentsConfig.swift index f640f62e9..7597121e4 100644 --- a/Core/Core/Configuration/Config/UIComponentsConfig.swift +++ b/Core/Core/Configuration/Config/UIComponentsConfig.swift @@ -13,7 +13,7 @@ private enum Keys: String { } public class UIComponentsConfig: NSObject { - public var isVerticalsMenuEnabled: Bool = false + public var isVerticalsMenuEnabled: Bool public var courseTopTabBarEnabled: Bool init(dictionary: [String: Any]) { diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index d7e76817e..9aff23491 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -2,3 +2,9 @@ API_HOST_URL: 'http://localhost:8000' ENVIRONMENT_DISPLAY_NAME: 'Localhost' FEEDBACK_EMAIL_ADDRESS: 'support@example.com' OAUTH_CLIENT_ID: '' + +UI_COMPONENTS: + COURSE_BANNER_ENABLED: false + COURSE_TOP_TAB_BAR_ENABLED: false + COURSE_UNIT_PROGRESS_ENABLED: false + COURSE_NESTED_LIST_ENABLED: false diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index d7e76817e..9aff23491 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -2,3 +2,9 @@ API_HOST_URL: 'http://localhost:8000' ENVIRONMENT_DISPLAY_NAME: 'Localhost' FEEDBACK_EMAIL_ADDRESS: 'support@example.com' OAUTH_CLIENT_ID: '' + +UI_COMPONENTS: + COURSE_BANNER_ENABLED: false + COURSE_TOP_TAB_BAR_ENABLED: false + COURSE_UNIT_PROGRESS_ENABLED: false + COURSE_NESTED_LIST_ENABLED: false diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index d7e76817e..9aff23491 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -2,3 +2,9 @@ API_HOST_URL: 'http://localhost:8000' ENVIRONMENT_DISPLAY_NAME: 'Localhost' FEEDBACK_EMAIL_ADDRESS: 'support@example.com' OAUTH_CLIENT_ID: '' + +UI_COMPONENTS: + COURSE_BANNER_ENABLED: false + COURSE_TOP_TAB_BAR_ENABLED: false + COURSE_UNIT_PROGRESS_ENABLED: false + COURSE_NESTED_LIST_ENABLED: false From 0de4dd1bcae329ecd10e4d9246b1c70e49a422f2 Mon Sep 17 00:00:00 2001 From: Eugene Yatsenko Date: Tue, 12 Dec 2023 11:43:44 +0100 Subject: [PATCH 8/8] chore: enable banner in course screen --- default_config/dev/config.yaml | 2 +- default_config/prod/config.yaml | 2 +- default_config/stage/config.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 9aff23491..04091f6f7 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -4,7 +4,7 @@ FEEDBACK_EMAIL_ADDRESS: 'support@example.com' OAUTH_CLIENT_ID: '' UI_COMPONENTS: - COURSE_BANNER_ENABLED: false + COURSE_BANNER_ENABLED: true COURSE_TOP_TAB_BAR_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false COURSE_NESTED_LIST_ENABLED: false diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 9aff23491..04091f6f7 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -4,7 +4,7 @@ FEEDBACK_EMAIL_ADDRESS: 'support@example.com' OAUTH_CLIENT_ID: '' UI_COMPONENTS: - COURSE_BANNER_ENABLED: false + COURSE_BANNER_ENABLED: true COURSE_TOP_TAB_BAR_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false COURSE_NESTED_LIST_ENABLED: false diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 9aff23491..04091f6f7 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -4,7 +4,7 @@ FEEDBACK_EMAIL_ADDRESS: 'support@example.com' OAUTH_CLIENT_ID: '' UI_COMPONENTS: - COURSE_BANNER_ENABLED: false + COURSE_BANNER_ENABLED: true COURSE_TOP_TAB_BAR_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false COURSE_NESTED_LIST_ENABLED: false