كيف عامل Custom TabView بـ SwiftUI و Lottie

في البداية قبل أن نبدأ خلونا نوضح بعض النقاط TabView هو نفسه TabBarController لذلك أحيانا اسميه TabView واحيانا TabBar في الأخير هو نفسه الفرق انه في SwiftUI يطلق عليه TabView وفي UIKit يطلق عليه TabBar

بشكل عام سوا SwiftUI او UIKit تخصيصه جداً محدود ، الي تقدر تعمله تغيير لون الـ Tab عند الضغط عليه واختيار لون الـ Tabs الغير مختاره ، والسماح بوضع نص او الاكتفاء بالأيقونات فقط وأيضا يمكن إخفاء الـ Tabbar في حال اردت اخفائه من بعض الصفحات الشكل الافتراضي

اذا كان تخصيص الـ TabView محدود ، فكيف يتم تخصيصه !؟

يتم تخصيصه بتصميم View بداخل HStack و يحتوي على Buttons وفي اعلاه View الصفحة اذا تغير الزر راح يتغير الـ View

بالنسبة للمستخدم لن يلاحظ اي فرق في الاداء ، بالنسبة للمطور هذا الامر يسهل عليه تخصيص الـ TabView ويظهر ابداعاته =)

لنبدا الشرح في البداية راح نحتاج الى Model لنميز كل Tab الي نحتاجه هو id و text و icon و tab الـ tab رقم الـ View

struct TabItem: Identifiable {
var id: Int
var text: String
var icon: String
var tab: Tab
}

enum Tab: Int {
case first
case second
}

قبل ان نبدا في اساس هذا الثريد ، الافضل تعمل View جديد تقدر تخليها نص او اي محتوى ثريده
انا عملت اثنين واحد باسم HomeView والاخر باسم ProfileView ومن اجل التأكد انه كل شي تمام اضفت List مع اضافة NavigationView

struct HomeView: View {
    var body: some View {
        List {
        ForEach((1...100).reversed(), id: \.self) {
            Text("\($0)…")
                .foregroundColor(.red)
        }
        }
        .listStyle(.plain)

    }
}
struct ProfileView: View {
    
    var body: some View {
        Text("Profile")
    }
}

الان لنبدا في صنع الـ TabBar اعمل View جديد باسم TabBar

في البداية بطبيعة الحال راح نحتاج نعرف الـ Tab المحدد بالاعتماد على متغير الـ selectedTab وايضا راح نحتاج array بجميع الايقونات راح نستخدم الـ Model الذي عملناه سابقا لاحظ اسم الايقونة الاولى home والثانية user

 @SceneStorage("selectedTab") private var selectedTab: Tab = .first
    
   private var tabItems = [
        TabItem(id: 0, text: "Home", icon: "home", tab: .first),
        TabItem(id: 1, text: "Profile", icon: "user", tab: .second),
    ]

الان نحتاج نصمم شكل الـ TabView عباره عن مستطيل بحواف ناعمه

ولاحظ استخدمت ultraThinMaterial هذه جديدة في iOS 15 تعطي تأثير Blur

راح تلاحظ شغلتين غريبه الاولى استخدمت GeometryReader والثانية متغير الـ hasHomeIndicator

struct TabBar: View {
    
    @SceneStorage("selectedTab") private var selectedTab: Tab = .first
    var tabItems : [TabItem]
    
    var body: some View {
        
        GeometryReader { proxy in
            let hasHomeIndicator = proxy.safeAreaInsets.bottom > 0
            
            
        HStack {
            
            
        }
        .padding(.bottom, hasHomeIndicator ? 16 : 0)
        .frame(maxWidth: .infinity, maxHeight: hasHomeIndicator ? 88 : 49)
        .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 34, style: .continuous))
        .frame(maxHeight: .infinity, alignment: .bottom)
        .ignoresSafeArea()
        
    }
    }
}

الـ GeometryReader يفيدك في معرفة حجم المساحة المستهلكه في الـ View
هنا استخدمته لمعرفة وجود شريط اللمس من عدمه الشريط الي بديل الـ Home Button من iPhone X ما اعرف ايش يطلق عليه xD
الفكره بإختصار اذا كانت المساحة 0 معناه الايفون 8 بزر الـ هوم فراح اجعل حجم الـ View بحجم 49

لكن اذا كان اكبر من 0 معناه الايقونة X وفوق ، الاجهزة التي تحتوي على شريط لمس فراح اجعل الـ View بحجم 88 الاحجام هذه هي احجام المتبعه في TabView مهي من عندي

ايضا راح اضيف 16 padding لرفع الايقونات عند وجود الشريط بعد اضافة الايقونات
جرب ازيل ال padding وشاهد الفرق

الان باقي الازرار ولكن قبل التطرق لها حمل الايقونات المستخدم ولا تنسى تغيير اسمها الى user و home

ايقونة home

ايقونة user

حملهم بصيغة Lottie JSON واضيفهم للمشروع بعد تغيير اسمائهم

الان حمل مكتبة Lottie واضيفها للمشروع بإستخدام SPM

اذا لاحظت في الكود السابق داخل الـ HStack مافي كود الان راح نضيفه

راح نعمل متغير tapButtons هو عباره عن ازرار الـ TabView وراح نضيفه لاحقا في داخل الـ Hstack

لاتنسى تضيف import Lottie

  var tapButtons: some View {
        ForEach(tabItems) { item in
          
                VStack(spacing: 0) {

                    
                    LottieButton(animation: .named(item.icon)) {
                        selectedTab = item.tab
                    }
                    .configure { lottieAnimationView in
                        
                        // don't allow taping on same item when it enable
                        lottieAnimationView.isEnabled = selectedTab != item.tab
                        
                        // play it once, when the item is active
                        if selectedTab == item.tab {
                            lottieAnimationView.animationView.play()
                        }
                        
                        // return item to beginning when it not selected
                        if selectedTab != item.tab {
                            lottieAnimationView.animationView.currentProgress = 0
                        }
                    }
                    .frame(width: 44, height: 29)
                    
                    
                    Text(item.text)
                        .font(.caption2)
                        .lineLimit(1)
                }
                .frame(maxWidth: .infinity)
            
            .foregroundColor(selectedTab == item.tab ? Color("sanmarino") : .secondary)
            
        }
    }

لاحظ في الكود وضحت ايش عملت اضفت Foreach ومريت على الاريه الي عملته سابقا ، تذكر الـ icon هو اسم ملف Lottie لكل ايقونة

بعدها استخدم LottieButton ومررت له اسم الايقونة وبداخل اقواس الـaction غيرت قيمة selectedTab بحيث عند الضغط على الزر يصبح قيمته قيمة الـ tab

اما بخصوص الـ configure هذه تخصيص للـ Lottiebutton
١- منعت المستخدم انه يضغط مرتين على نفس الزر ، مدام الزر مفعل فلن يستطيع المستخدم الضغط عليه
٢- جعلت الزر يعمل تلقائيا، هذه ضروريه بحيث لما تفتح التطبيق لاول مره راح يكون محدد على اول ايقونة فراح يشتغل الانميشين تلقائيا
٣- عند الضغط على ايقونة الاخرى راح ارجع قيمة الزر السابق الى القيمة الافتراضيه بحيث عند الضغط عليه فيما بعد يعمل الانميشين مره اخرى

الكود النهائي لهذه الصفحة سوف يصبح بهذا الشكل

import SwiftUI
import Lottie

struct TabBar: View {
    
    @SceneStorage("selectedTab") private var selectedTab: Tab = .first
    var tabItems : [TabItem]
    
    var body: some View {
        
        GeometryReader { proxy in
            let hasHomeIndicator = proxy.safeAreaInsets.bottom > 0
            
            
        HStack {
            
            
        }
        .padding(.bottom, hasHomeIndicator ? 16 : 0)
        .frame(maxWidth: .infinity, maxHeight: hasHomeIndicator ? 88 : 49)
        .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 34, style: .continuous))
        .frame(maxHeight: .infinity, alignment: .bottom)
        .ignoresSafeArea()
        
    }
    }
    
    var tapButtons: some View {
        ForEach(tabItems) { item in
          
                VStack(spacing: 0) {

                    
                    LottieButton(animation: .named(item.icon)) {
                        selectedTab = item.tab
                    }
                    .configure { lottieAnimationView in
                        
                        // don't allow taping on same item when it enable
                        lottieAnimationView.isEnabled = selectedTab != item.tab
                        
                        // play it once, when the item is active
                        if selectedTab == item.tab {
                            lottieAnimationView.animationView.play()
                        }
                        
                        // return item to beginning when it not selected
                        if selectedTab != item.tab {
                            lottieAnimationView.animationView.currentProgress = 0
                        }
                    }
                    .frame(width: 44, height: 29)
                    
                    
                    Text(item.text)
                        .font(.caption2)
                        .lineLimit(1)
                }
                .frame(maxWidth: .infinity)
            
            .foregroundColor(selectedTab == item.tab ? Color("sanmarino") : .secondary)
            
        }
    }
}

الان ما بقي شي الا اننا نغير الـ View حسب الزر باستخدم Switch مع ZStack فنقدر نعملها مباشره في ContentView والوضع راح يكون تمام لكني افضل احسن الكود اولا =)

انشات View جديدة بإسم TabBarView الـ View هذا فكرته بسيطه مررت له متغير الـ array الي هو tabItems
استفدت من الـ content ووضعته في zstack والـ Tabbar
وضعته تحته بما يعني الان الـ TabBar راح يظهر فوق الـ content

struct TabBarView <Content : View> : View {
    var content : Content
    var tabItems : [TabItem]

    init(tabItems : [TabItem], @ViewBuilder content: () -> Content) {
        self.tabItems = tabItems
        self.content = content()
    }
    
    var body: some View {
        
        ZStack(alignment: .bottom) {

            content
            
            TabBar(tabItems: tabItems)
       
        }    
    }
}

ايش الفائدة من الكود السابق ؟ جعل تصميم الـ Custom TabView بنفس طريقة النظام بمعنى المبرمج يحتاج يستخدمه بهذا الشمل

TabBarView(tabItems: tabItems) {}

داخل الاقواس يضيف الـ View

انتهينا ؟ لا عملت View اخر بإسم MainTabView هذا الـ View الاساسي للتطبيق لاحظ قمت بازالت الاريه من TabBar ونقلته الى هنا

struct MainTabView: View {
    
    @SceneStorage("selectedTab") private var selectedTab: Tab = .first
    
   private var tabItems = [
        TabItem(id: 0, text: "Home", icon: "home", tab: .first),
        TabItem(id: 1, text: "Profile", icon: "user", tab: .second),
    ]
    
    var body: some View {
        
        NavigationView {
        
            TabBarView(tabItems: tabItems) {
            
            Group {
                switch selectedTab {
                case .first:
                    HomeView()
 
                case .second:
                    ProfileView()
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)

         
        }
        .navigationTitle("Test")
        .navigationBarTitleDisplayMode(.inline)
        }
    }
}

اضفت الـ NavigationView اولا ومن ثم TabBarView
ومررت لها قيمة الاريه وضعت الـ switch اعتمادا على selectedTab
راح اعرض الـ View الي ضغط عليه المستخدم اذا ضغط على tab الاول راح اعرض HomeView
اذا على الثاني راح اعرض ProfileView

ايش الفائدة من Group ؟ Group في SwiftUI ماتعتبر View بمعنى تعتبر شي شفاف او مخفي وظيفتها فقط انه اذا استخدم Modifier تستخدمه على الكل عشان توضح الصورة الكود تقدر تكتبر كذا لكن التكرار ماله لزمه =)

اذا لاحظت استخدمت

.frame(maxWidth: .infinity, maxHeight: .infinity)

هذه وظيفتها انه تعطي للـ View كامل المساحه المتوفره بما اني راح اعرض View او صفحه فابغاها تاخد كامل المساحة المتوفره

النتيجة النهائية