Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Course tab bar from the bottom of the screen move to the top #181

Merged
merged 15 commits into from
Jan 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions Core/Core.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollSlidingTabBar.swift; sourceTree = "<group>"; };
BA593F1D2AF8E4A0009ADB51 /* FrameReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrameReader.swift; sourceTree = "<group>"; };
BAAD62C52AFCF00B000E6103 /* CustomDisclosureGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDisclosureGroup.swift; sourceTree = "<group>"; };
A53A32342B233DEC005FE38A /* ThemeConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThemeConfig.swift; sourceTree = "<group>"; };
BA30427D2B20B299009B64B7 /* SocialAuthError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SocialAuthError.swift; sourceTree = "<group>"; };
BA76135B2B21BC7300B599B7 /* SocialAuthResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthResponse.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -637,6 +643,7 @@
027BD3C42909707700392132 /* Shake.swift */,
023A1135291432B200D0D354 /* RegistrationTextField.swift */,
023A1137291432FD00D0D354 /* FieldConfiguration.swift */,
BA593F1A2AF8E487009ADB51 /* ScrollSlidingTabBar */,
BAAD62C52AFCF00B000E6103 /* CustomDisclosureGroup.swift */,
BA8FA6672AD59A5700EA029A /* SocialAuthButton.swift */,
02E93F862AEBAED4006C4750 /* AppReview */,
Expand Down Expand Up @@ -673,6 +680,15 @@
path = Providers;
sourceTree = "<group>";
};
BA593F1A2AF8E487009ADB51 /* ScrollSlidingTabBar */ = {
isa = PBXGroup;
children = (
BA593F1D2AF8E4A0009ADB51 /* FrameReader.swift */,
BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */,
);
path = ScrollSlidingTabBar;
sourceTree = "<group>";
};
C9DFE47E699CFFA85A77AF2C /* Pods */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -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 */,
Expand Down
2 changes: 1 addition & 1 deletion Core/Core/Configuration/Config/FeaturesConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Core/Core/Configuration/Config/UIComponentsConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down
15 changes: 15 additions & 0 deletions Core/Core/Extensions/ViewExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
} else {
self
}
}
}

private struct FirstAppear: ViewModifier {
let action: () -> Void

Expand Down
45 changes: 45 additions & 0 deletions Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift
Original file line number Diff line number Diff line change
@@ -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
}
242 changes: 242 additions & 0 deletions Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift
Original file line number Diff line number Diff line change
@@ -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<Int>,
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
Loading