Animated Auto Scrollable Header Menu
Updated:
Animated Auto Scrollable Header Menu
Source Code - https://github.com/jacobkosmart/Animated-Auto-Scrollable-Header-Menu-Practice
상단 카테고리 영역을 클릭하면 자동으로 스크롤 되서 리스트가 업데이트 되며, 스크롤 할 경우에도 자동으로 상단 카테고리 영역도 업데이트 되는 기능 구현
🔶 DB
Data 와 Asset 은 최근 프로젝트인 멸종위기동물사전 APP 에서 가져와서 사용하였음
endangered-animals-kr-app - https://github.com/jacobkosmart/endangered-animals-kr-app
- type 과 body 를 추가해서 type1 ~ type 5 단계 별로 json 파일을 변형 함 ->
animalType.json
파일 참조
🔶 Decode json
// in CodableBundleExtension.swift
import Foundation
extension Bundle {
func decode<T: Codable>(_ file: String) -> T {
// 1.Locate the json file
guard let url = self.url(forResource: file, withExtension: nil) else {
fatalError("Failed to locate \(file) in bundle")
}
// 2.Create a property for the data
guard let data = try? Data(contentsOf: url) else {
fatalError("Failed to load \(file) from bundle")
}
// 3.Create a decoder
let decoder = JSONDecoder()
// 4.Create a property for the decoded data
guard let loaded = try? decoder.decode(T.self, from: data) else {
fatalError("Failed to decode \(file) from bundle.")
}
// 5.Return the ready-to-use data
print(loaded)
return loaded
}
}
🔶 Model
// AnimalModel.swift
import Foundation
import SwiftUI
// Sample Tabs with sample animals
struct AnimalType: Codable{
var type : String
var body: [Animal]
}
struct Animal: Codable, Identifiable {
var id : String
let name: String
let headline: String
let description: String
let link: String
let image: String
let gallery: [String]
let fact: [String]
let copyright: String
}
// Tab Model ..
struct Tab: Identifiable {
var id = UUID().uuidString
var tab: String
var animals: [Animal]
}
🔶 ViewModel
// AnimalViewModel.swift
import SwiftUI
class AnimalViewModel: ObservableObject {
// MARK: - PROPERTY
@Published var animalTypes: [AnimalType]
@Published var tabItems: [Tab]
@Published var currentTab:String = ""
init() {
let fetchData: [AnimalType] = Bundle.main.decode("animaltype.json")
self.animalTypes = fetchData
self.tabItems = [
Tab(tab: "Type1", animals: fetchData[0].body),
Tab(tab: "Type2", animals: fetchData[1].body),
Tab(tab: "Type3", animals: fetchData[2].body),
Tab(tab: "Type4", animals: fetchData[3].body),
Tab(tab: "Type5", animals: fetchData[4].body)
]
}
}
🔶 ContentView
// ContentView.swift
import SwiftUI
struct ContentView: View {
// MARK: - PROPERTY
@EnvironmentObject private var vm: AnimalViewModel
@Namespace var animation
@Environment(\.colorScheme) var scheme
// MARK: - BODY
var body: some View {
ZStack {
// Background
// foreground
VStack (spacing: 0){
VStack {
header
categoryView
.padding([.top])
bodyView
} //: VSTACK
} //: VSTACK
.padding([.horizontal, .top])
} //: ZSTACK
.background(.ultraThinMaterial)
}
}
// MARK: - PREVIEW
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(AnimalViewModel())
}
}
// MARK: - EXTENSTION
extension ContentView {
// Header
private var header: some View {
HStack (spacing: 15) {
Button {
} label: {
Image(systemName: "list.dash").font(.title2)
}
Text("Animal List")
.font(.title3)
.fontWeight(.semibold)
.hLeading()
Button {
} label: {
Image(systemName: "magnifyingglass")
.font(.title2)
}
} //: HSTACK
.foregroundColor(.primary)
.padding(.horizontal)
}
// Category View
private var categoryView: some View {
// Scroll View Reader..
// to scroll tab automatically when user scrolls..
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
HStack (spacing: 30) {
ForEach(vm.tabItems) { tab in
VStack {
Text(tab.tab)
.foregroundColor(vm.currentTab.replacingOccurrences(of: " SCROLL", with: "") == tab.id ? .primary : .gray)
// For matched geometry effect..
if vm.currentTab.replacingOccurrences(of: " SCROLL", with: "") == tab.id {
Capsule()
.fill(Color.accentColor)
.matchedGeometryEffect(id: "TAB", in: animation)
.frame(height: 3)
.padding(.horizontal, -10)
} else {
Capsule()
.fill(.clear)
.frame(height: 3)
.padding(.horizontal, -10)
}
} //: VSTACK
.onTapGesture {
withAnimation(.easeInOut) {
vm.currentTab = "\(tab.id) TAP"
proxy.scrollTo(vm.currentTab.replacingOccurrences(of: " TAP", with: ""), anchor: .topTrailing)
}
}
} //: LOOP
} //: HSTACK
.padding(.horizontal, 30)
.background(.ultraThinMaterial)
} //: SCROLL
.onChange(of: vm.currentTab, perform: { _ in
// Enabling scrolling..
if vm.currentTab.contains(" SCROLL") {
withAnimation(.easeInOut) {
proxy.scrollTo(vm.currentTab.replacingOccurrences(of: " SCROLL", with: ""), anchor: .topTrailing)
}
}
})
// Divider
.background(scheme == .dark ? Color.black : Color.white)
.overlay(
Divider()
.padding(.horizontal, -15)
, alignment: .bottom
)
} //: SCROLLREADER
// Setting first tab..
.onAppear {
vm.currentTab = vm.tabItems.first?.id ?? ""
}
}
// Body View
private var bodyView: some View {
// Scrool view reader to scroll the content..
ScrollView(.vertical, showsIndicators: false) {
ScrollViewReader { proxy in
VStack (spacing: 15) {
ForEach(vm.tabItems) { tab in
// Animal Card Item
AnimalCardView(tab: tab, currentTab: $vm.currentTab)
}
} //: VSTACK
.padding([.bottom])
.onChange(of: vm.currentTab) { newValue in
// avoid scroll if its tap..
if vm.currentTab.contains(" TAP") {
// Scrolling to content..
withAnimation(.easeInOut) {
proxy.scrollTo(vm.currentTab.replacingOccurrences(of: " TAP", with: ""), anchor: .topTrailing)
}
}
}
} //: SCROLLREADER
} //: SCROLL
// Setting Coordinate Space name for offset..
.coordinateSpace(name: "SCROLL")
}
}
struct AnimalCardView: View {
let tab: Tab
@Binding var currentTab: String
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text(tab.tab)
.font(.title.bold())
.padding(.vertical)
ForEach(tab.animals) { animal in
HStack(alignment: .center, spacing: 16) {
Image(animal.image)
.resizable()
.scaledToFill()
.frame(width: 90, height: 90)
.clipShape(
RoundedRectangle(cornerRadius: 12)
)
VStack(alignment: .leading, spacing: 8) {
Text(animal.name)
.font(.title2)
.foregroundColor(.accentColor)
Text(animal.headline)
.font(.footnote)
.multilineTextAlignment(.leading)
.lineLimit(2)
.padding(.trailing, 8)
} //: VSTACK
} //: HSTACK
Divider()
} //: LOOP
} //: VSTACK
.modifier(OffsetModifier(tab: tab, currentTab: $currentTab))
.id(tab.id)
}
}
🔶 OffsetModifier
// OffsetModifier.swift
import SwiftUI
struct OffsetModifier: ViewModifier {
var tab: Tab
@Binding var currentTab: String
func body(content: Content) -> some View {
content
.overlay(
// Getting Scroll Offset using Geometry Reader..
GeometryReader { proxy in
Color.clear
.preference(key: OffsetKey.self, value: proxy.frame(in: .named("SCROLL")))
}
)
.onPreferenceChange(OffsetKey.self) { proxy in
print(proxy.minY)
// if minY is between 20 to -half of the midX
// then updating current tab..
// Since on chnage on Content is updating Scroll..
// to avoid that..
// Adding "SCROLL" to last of ID..
// To identify Easily..
let offset = proxy.minY
withAnimation(.easeInOut) {
currentTab = (offset < 20 && -offset < (proxy.midX / 2) && currentTab != tab.id) ? "\(tab.id) SCROLL" : currentTab
}
}
}
}
struct OffsetModifier_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
// Preference Key..
struct OffsetKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
🔶 CustomAlignment
import SwiftUI
extension View {
// MARK: - Vertical Center
func vCenter() -> some View {
self.frame(maxHeight: .infinity, alignment: .center)
}
// MARK: - Vertical Top
func vTop() -> some View {
self.frame(maxHeight: .infinity, alignment: .top)
}
// MARK: - Vertical Bottom
func vBottom() -> some View {
self.frame(maxHeight: .infinity, alignment: .bottom)
}
// MARK: - Horizontal Center
func hCenter() -> some View {
self.frame(maxWidth: .infinity, alignment: .center)
}
// MARK: - Horizontal Leading
func hLeading() -> some View {
self.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Horizontal Trailing
func hTrailing() -> some View {
self.frame(maxWidth: .infinity, alignment: .trailing)
}
}
Leave a comment