نصائح لمبرمجين SwiftUI

هذا المقال تم كتابته ايام SwiftUI 3 لذلك جزئية الـ Navigation مكتوبه على أساس NavigationView، سيتم شرح NavigationStack بشكل مستقل مستقبلاً

١- هل تعلم بأنك تستطيع استخدام SwiftUI في Xcode Playground ؟

تحتاج فقط تضيف PlaygroundSupport واذا اردت عرض الـ View تستطيع اضافته ضمن الأقواس PlaygroundPage.current.setLiveView())

مثل المثال التالي

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    var body: some View {
        Text ("Text" )
    }
// To display SwiftUI View using Playground
PlaygroundPage.current.setLiveView(ContentView())

٢- اذا اردت دمج عدت نصوص بدون الحاجة لاستخدام Hstack تستطيع استخدام “+” مثل المثال التالي

import SwiftUI

struct ContentView: View {
    
var body: some View {
    
Text ("Red")
.foregroundColor(.red)
+
Text ("Green")
.foregroundColor(.green)
+
Text ("Blue")
.foregroundColor(.blue)
}
    
}

٣- اصنع Custom ViewModifier في اي وقت تحتاج تعيد استخدام ستايل معين ، سوا كان حجم ،لون، نوع للخط ، او حجم صورة او غيرها

اذا عندك ستايل يتكرر مثل المثال التالي

// Before
struct ContentView: View {
    var body: some View {
        VStack {
            Text ("Titlel")
                .bold()
                .font(.largeTitle)
                .foregroundColor(.blue)
                .shadow(radius: 2)
            
            Text ("Title2")
                .bold()
                .font(.largeTitle)
                .foregroundColor(.blue)
                .shadow(radius: 2)
        }
    }
}

تستطيع عمل ViewModifier بالشكل التالي

struct Title: ViewModifier {
    func body(content: Content) -> some View {
        content
            .bold()
            .font(.largeTitle)
            .foregroundColor( .blue)
            .shadow(radius: 2)
    }
}

وتستخدمه بهذا الشكل ، لاحظ بانه يجب عليك استخدام نوع modifier. وايضا يجب عليك تذكر اسم ViewModifier وهو امر مزعج

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            Text ("Titlel")
                .modifier(Title())

            Text ("Title2")
            .modifier(Title())
        }
    }
}

لذا تستطيع اختصاره بهذا الشكل بتحويله الى View باستخدام الـ Extension

extension View {
    func titleStyle() -> some View {
        self.modifier(Title())
    }
}

والنتيجة تستطيع الان استخدامه مع اي نص بالطريقة التالية

import SwiftUI

// After
struct ContentView: View {
var body: some View {
VStack {

Text("Title1")
.bold()
.titleStyle()

Text("Title2")
.bold()
.titleStyle()
}
}
}

٤- قبل اصدار Xcode 12.5 وايضا نظام iOS 14.5 ما كان تقدر تضيف اكثر من sheet في View وحده والكلام ذا يشمل fullScreenCover وايضا Alert بمعنى فقط تقدر تضيف نوع واحد فقط ، ولكن هناك طريقة اخرى لاضافة كذا نوع وهي باستخدام Enum مع item بدل من isPresented

الخطوة الاولى : اضيف enum باي اسم تريده ، واضيف اسماء مختلفه مثال

الخطوة الثانية : عرف متغير من نوع الـ enum الذي كتبته في الخطوه السابقة وتأكد من كتابه كنوع State

import SwiftUI

enum ActiveSheet: Identifiable{
case first, second

var id: Int {
hashValue
}
}

الخطوة الثالثه : بدل من كتابة .toggle او true اضيف اسم النوع مثال activeSheet = .first

الخطوة الرابعه بدل من استخدام isPresented استخدم item وايضا استخدم switch لتنقل بينهم وضع الـ View المراد فتحه

import SwiftUI

struct YourView: View {
// Step 2
@State var activeSheet: ActiveSheet?

    var body: some View {
        VStack {
    
            Button {
                // Step 3
                activeSheet = .first
            } label: {
                    Text ("Activate first sheet" )
                }
                
                Button {
                    // Step 4
                    activeSheet = .second
                } label: {
                    Text("Activate second sheet" )
                }
                
            }
            // Step 5
            .sheet( item: $activeSheet) { item in
                switch item {
                case first:
                    FirstView()
                case. second:
                    SecondView()
                    
                }
            }
        }
    }

الان عرفت الطريقة الصحيحه ، لكن ماذا تتوقع ان يحدث اذا وضع اثنين من الـ sheet في نفس الفيو بدون الاعتماد على هذه الطريقة ؟ الي راح يصير اي مستخدم قبل iOS 14.5 راح يقدر يظهر الـ sheet الاخر ولكن لن يستطيع فتح اول sheet لذلك اذا تدعم نظامين iOS 14 و iOS 15 اتبع الطريقة السابقه =)

وكما ذكرت سابقا هذا الامر يشمل ايضا مع الـ fullScreenCover وايضا alert

٥- قسم الـ Views الى SubViews ابل تنصحك تقسم الـ View الى عدة اجزاء ولكن لماذا ؟

هناك عدة اسباب الأول : تسهيل قراءة الكود

الثاني : سهولة اعادة استخدام الـ View عند تقسميه الى SubView وبالتالي تستطيع استخدام في View اخر بدون مشاكل

الثالث : منع اعادة بناء الـ View في كل مره تتغير قيمة الـ State سيتم اعادة بناء الـ View في حال فصلت الاجزاء التي لا تحتاج الى الاعتماد على حالة الـ State لن يعاد بنائها عند تغيير حالة الـ State رابعا : اذا الـ View كان جدا معقد الـ Preview لن يعمل وسظهر لك خطأ

مثال للمشكله تغيير حالة الـ State لاحظوا الكود والفيديو الكود فكرته انه يعرض رقم عشوائي يوجود زر يعرض Alert عند الضغط عليه لاحظوا كل مره يتم الضغط على الزر الرقم يتغير والسبب لانه قيمة State تغيرت وبالتالي يتم اعادة بناء الواجهه من جديد

struct ContentView: View {
    
    @State private var isAlertShown = false
    
    var body: some View {
        VStack {
            Text("Random Number : \(Int.random(in: 0...1000))")
                .font(.largeTitle)
            Button("Present Alert ") {
                isAlertShown = true
            }
            .alert(isPresented: $isAlertShown) {
                Alert(title: Text("Showing Alert"), message: nil,
                      dismissButton: .cancel())
            }
        }
    }
}

الفيديو الذي يظهر المشكله

٦- احيانا ما تحتاج تسوي SubView للـ View فتقدر تعملها كـ Computed property او كـ Function بهذه الطريقة في المثال التالي لاحظ الـ changeColorButton

struct ContentView: View {
    @State private var changeColor = false
    @State private var color: Color = .black
    
    var body: some View {
        NavigationView {
            List {
                ForEach(0...100, id:\.self) { i in
                    Text("Test \(i)")
                        .foregroundColor (color )
                }
                .listStyle(GroupedListStyle())
                
            }
            .navigationTitle("List")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    changeColorButton
                }
            }
        }
    }
    
      @ViewBuilder var changeColorButton: some View {
            Button(action: toggleButton) {
                if changeColor {
                    Text("Change to Black")
                } else {
                    Text ("Change to Red" )
                }
            }
        }
        
       func toggleButton() {
            withAnimation {
                changeColor.toggle()
                if changeColor {
                    color = .red
                } else {
                    color = .black
                }
            }
        }
        
    }

الطريقة الاخرى هيا عمل Function بهذه الطريقة

struct ContentView: View {
    @State private var changeColor = false
    @State private var color: Color = .black
    
    var body: some View {
        NavigationView {
            List {
                ForEach(0...100, id:\.self) { i in
                    Text("Test \(i)")
                        .foregroundColor (color )
                }
                .listStyle(GroupedListStyle())
                
            }
            .navigationTitle("List")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    changeColorButton()
                }
            }
        }
    }
    
      @ViewBuilder func changeColorButton() -> some View {
            Button(action: toggleButton) {
                if changeColor {
                    Text("Change to Black")
                } else {
                    Text ("Change to Red" )
                }
            }
        }
        
       func toggleButton() {
            withAnimation {
                changeColor.toggle()
                if changeColor {
                    color = .red
                } else {
                    color = .black
                }
            }
        }
        
    }

لاحظ بأن الطرقتين تعتمد على انه تكون ضمن أقواس الـ View

نتيجة الكود السابق بالنسبة لـ SwiftUI الطرقتين لا تختلف عن وضعها بداخل الـ Body لكن بالنسبة لك كمطور فهي تنظم الكود وتجعله سهل القراءة

٧. حاول أن تتجنب استخدام if , else أو if let , else في داخل الـ View والسبب لانك راح تسبب في كسر الـ Structural identity بما يعني SwiftUI ماراح تفهم الشرط وسوف تقوم بتدمير الـ View واعادة بنائه في كل مره يتغير الشرط

بما يعني عند استخدام if else راح يسبب في تدمير الـ View واعادة بنائه وبالتالي راح تلاحظ مشكلة في الانميشين لذا استبدلها بـ if المختصره او كما تسمى Ternary operator

لاحظ التغيير الذي عملته من الكود السابق

struct ContentView: View {
    @State private var changeColor = false
    @State private var color: Color = .black
    
    var body: some View {
        NavigationView {
            List {
                ForEach(0...100, id:\.self) { i in
                    Text("Test \(i)")
                        .foregroundColor (color )
                }
                .listStyle(GroupedListStyle())
                
            }
            .navigationTitle("List")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    changeColorButton
                }
            }
        }
    }
    
    @ViewBuilder var changeColorButton: some View {
        Button(action: toggleButton) {
            changeColor ? Text("Change to Black") : Text("Change to Red")
        }
    }
    
        func toggleButton() {
            withAnimation {
                changeColor.toggle()
                changeColor ? (color = .red) : (color = .black)

            }
        }
        
    }

ملاحظة : عند تجربة المثالين السابقين لم اجد اي فرق في اصدار Xcode 15
لكن بشكل عام، استخدام Ternary operator افضل لـ SwiftUI في بناء الـ View من استخدام if else

٨- تجنب استخدام AnyView في مشروعك قبل كل شي يجب أن اوضح ما الغرض من AnyView ولتوضيحها يجب أن تفهم كيف يعمل SwiftUI

من شروط SwiftUI يجب عليك ان ترجع View من نوع واحد فقط لذلك نستخدم VStack و HStack وغيرها بحيث في الاخير راح نرجع View واحد فقط ، لكن هناك حالات كثيره تحتاج ترجع نوعين مختلفه باستخدام Function او Computed property كما شرحتها في التغريدات السابقة

مثل المثال هذا تريد تعديل النص في حال قيمة isEditable قيمتها true ولكن تريد تعرض النص في حال كانت القيمة false

في المثال السابق الـ Compiler اشتكى من اختلاف النوع تستطيع في هذا الوقت استخدام AnyView بالشكل هذا

struct ContentView: View {
    @State private var textField: String = ""
    @State private var isEditable: Bool = false
    var body: some View {
        VStack {
            Button {
                isEditable.toggle()
            } label: {
                Text( "Edit" )
            }
            textView
        }
    }
    
    private var textView: some View {
        if isEditable {
            return AnyView(TextField( "Name", text: $textField))
        } else {
            return AnyView(Text("\(textField)" ))
        }
        
    }
}

صحيح بأن الكود السابق يعمل بدون مشاكل بشكل ظاهري ، لكن سوف يسبب مشاكل في الاداء ، لانه SwiftUI تستخدم Type-based algorithm لتقرر متى تعمل update للـ View ومتى ما تعمل ولانه AnyView نوعه مبهم ويمكن تغليف اي View بداخله ٍSwiftUI لن تستطيع معرفة متى تحدث الـ View

لذلك ينصح بتجنبه وبدل من ذلك الاعتماد ViewBuilder استخدام ViewBuilder يعطيك نفس المبدا الذي يعمل عليه الـ Body في SwiftUI بحيث يعتمد على Function builders with conditions بالطريقة هذه راح تقدر تستخدم نوعين مختلفه وايضا SwiftUI راح تقدر تعرف نوع كل View

ظاهرياً لا يوجد فرق كل الطرقتين تعمل ، لكن كاداء استخدام ViewBuilder افضل

اذا كيف تستخدمه ؟ فقط اضيف ViewBuilder فوق الـ Function او الـ Computed property مثل المثال التالي لاحظ باني ازلت الـ return وايضا الـ AnyView

struct ContentView: View {
    @State private var textField: String = ""
    @State private var isEditable: Bool = false
    
    var body: some View {
        VStack {
            Button {
                isEditable.toggle()
            } label: {
                Text("Edit")
            }
            textView
        }
    }
    
    @ViewBuilder
    private var textView: some View {
        if isEditable {
            TextField( "Name", text: $textField)
        } else {
            Text("textField hidden")
        }
    }
}

١٠- وحده من المشاكل التي لم تحل حتى iOS 16 هي اتجاه ScrollView

المشكلة بإختصار مستحيل تقدر تخلي الـ ScrollView من اليمين الى اليسار (RTL) حتى اذا تطبيقك يدعم اللغه العربية وكل الواجهة اصبحت من اليمين الى اليسار راح يظل الـ ScrollView من اليسار الى اليمين (LTR)

تبغى تتأكد من كلامي ، جرب هذا الكود الكود بإختصار يطبع ١٠٠ رقم في ScrollView بشكل افقي ، الان الوضع طبيعي بما انه الواجهة انجليزية LTR

struct ContentView: View {
    var body: some View {
        ScrollView( .horizontal) {
            HStack {
                ForEach( (1...100), id: \.self) {
                    Text("\($0)")
                }
            }
        }
        .environment(\.layoutDirection, .rightToLeft)

    }
}

ملاحظة : تم اضافة هذا السطر لإجبار التطبيق على تحويله الى اليمين ، لمحاكاة الواجهه العربية

        .environment(\.layoutDirection, .rightToLeft)

عند تشغل المشروع راح تلاحظ ما تغير شي لازال الـ ScrollView بوضع الافقي horizontal ولازال من اليسار الى اليمين LTR

للتذكير هذه المشكلة تم حلها في نظام iOS 16 و SwiftUI 4 ، لذا ما سوف اذكره هو في حال تطبيقك يدعم اصدار iOS 15 واقدم فقط

بما انك شاهدة المشكلة فماهو الحل ؟ الحل باختصار هو عكس (قلب) اتجاه الـ ScrollView ومن ثم عكس الـ HStack

بهذه الطريقة راح نجبر الـ ScrollView يبدا من اليمين الى اليسار

 struct ScrollViewRTL<Content: View>: View {
     @ViewBuilder var content: Content
    @Environment(\.layoutDirection) private var layoutDirection

    @ViewBuilder var body: some View {
        if #available(iOS 16.0, *) {
            ScrollView(.horizontal, showsIndicators: false) {
                content
            }
        } else {
            ScrollView(.horizontal, showsIndicators: false) {
                content
                    .rotation3DEffect(Angle(degrees: layoutDirection == .rightToLeft ? -180 : 0), axis: (x: CGFloat(0), y: CGFloat(layoutDirection == .rightToLeft ? -10 : 0), z: CGFloat(0)))
            }
            .rotation3DEffect(Angle(degrees: layoutDirection == .rightToLeft ? 180 : 0), axis: (x: CGFloat(0), y: CGFloat(layoutDirection == .rightToLeft ? 10 : 0), z: CGFloat(0)))

        }
    }
}

مع العلم الكود السابق سوف يعمل في جميع الاصدارات من اصدار iOS 14 الى احدث اصدار

الطريقة هذه قد تكون غريبه لك لانه ماشرحتها سابقا ولكن بإختصار هو مجرد View جديد يحتوي على <Content: View> هذه بإختصار هيا الاقواس وبعدها تعرف القيمة في متغير جديد لاستخدامه بداخل الـ View الطريقة هذه مفيده جداً لاعادة استخدام الـ View

الان فقط استبدل ScrollView بـ ScrollViewRTL

struct ContentView: View {
    var body: some View {
        ScrollViewRTL {
            HStack {
                ForEach( (1...100), id: \.self) {
                    Text("\($0)")
                }
            }
        }
        .environment(\.layoutDirection, .rightToLeft)

    }
}

النقاط التالية راح تكون عن NavigationView

١١- الـ NavigationView يجب أن يكون في اول View فقط ! من الاخطاء الشائعه وضع الـ NavigationView في كل الصفحات

مثال لصفحتين الاولى فيها NavigationView بعنوان Page 1 والثاني ايضا فيها NavigationView بعنوان Page2

struct PageOne: View {
    var body: some View {
        NavigationView {
            NavigationLink( "Page Two", destination: PageTwo())
                .navigationTitle("Page One")
            }
        }
    }


struct PageTwo: View {
    var body: some View {
        NavigationView {
            Text ("Demo... " )
                .navigationTitle( "Page Two" )
        }
    }
}

الطريقة هذه غير صحيحه وتسبب مشكله ظهور مسافة فاضيه !
لاحظ الفيديو التالي

السبب بكل اختصار لانك اضفت اثنين من الـ NavigationView فوق بعض ، والصح هو وضع NavigationView واحد فقط المقصد هنا اعتبر الامر كهيكل شجري مدام تتنقل من صفحة لاخرى في نفس اللفل ماراح تحتاج غير NavigationView واحد

لكن اذا مثال ضغط زر وفتحت View جديد باستخدام fullScreenCover هنا راح تنتقل الى لفل جديد فراح تحتاج تسوي NavigationView جديد لاظهاره

نرجع لحل المشكله بإختصار هو الاعتماد على NavigationView واحد فقط

struct PageOne: View {
    var body: some View {
        NavigationView {
            NavigationLink( "Page Two", destination: PageTwo())
                .navigationTitle("Page One")
            }
        }
    }


struct PageTwo: View {
    var body: some View {
            Text ("Demo... " )
                .navigationTitle( "Page Two" )  
    }
}

١٢- من الاخطاء الشائعه وضع اي modifiers متعلق بالـ NavigationView بعد اقواس الـ NavigationView

مثل المثال التالي اذا شغلت الكود لن تجد العنوان لانه اي شغله متعلق بالـ NavigationView لن تعمل في خارج اقواسها

struct PageOne: View {
    var body: some View {
        NavigationView {
            NavigationLink( "Page Two", destination: PageTwo())
            }
        .navigationTitle("Page One")

        }
    
    }

ملاحظة هذه الفقرة سيتم شرح جزئية متعلقة بالـ NavigationView وهي طريقة قديمة كانت تستخدم قبل اصدار iOS 16 سيتم شرح الطريقة الجديدة مستقبلاً بإستخدام NavigationStack

١٣- الـ NavigationLink يستخدم مع الـ NavigationView ، وظيفته مثل وظيفة الزر انه ينقل المستخدم من صفحة الى صفحة اخرى وتلقائيا يعطيك ميزة زر الرجوع Back بمعنى الـ NavigationLink وهو الي يخليك تنتقل للصفحه التاليه وايضا الي راح يرجعك للصفحة السابقة

في العادة ماراح تشوف isActive لانه تكون موجوده بشكل افتراضيه لكن تحتاج توصلها اذا تبغى تتحكم متى ترجع للـ Root الـ Root = اول View

كما ذكرت سابقا المفترض يكون في NavigationView اول View الي يحتوي على NavigationView هو الـ RootView في حالات كثيره تحتاج ترجع عدة صفحات للخلف

بدل ما تحتاج تضغط زر الـ Back عدة مرات مثال لـ 3 صفحات اخر صفحة تريد ترجع للـ Root مباشره بدون الحاجة الى الرجوع لصفحة 2 وبعدها صفحة 1

struct PageOne: View {
    @State private var isActive: Bool = false
    var body: some View {
        NavigationView {
            NavigationLink(
                destination: PageTwo(isActive: $isActive),
                isActive: self.$isActive,
                label: {
                    Text("Go to page two")
                })
            .navigationTitle( "Page One" )
            
            .navigationTitle( "Page One" )
            
        }
    }
}

struct PageTwo: View {
    @Binding var isActive: Bool
    var body: some View {
        NavigationLink(destination: PageThree(isActive: $isActive)) {
            Text ("Go to Page Three")
        }
        .navigationTitle( "Page Two" )
    }
}

struct PageThree: View {
    @Binding var isActive : Bool
    var body: some View {
        
        Button(action: {
            isActive = false
            
        }, label: {
            Text("Back To Root View")
        })
        .navigationTitle ( "Page Three")
    }
}

لاحظ الكود السابق،في أول View معرف State بإسم isActive واهم شغله لاحظ NavigationLink في اول View مستخدم بارميتر isActive

في المثال هذا اريد ان ارجع من صفحة ٣ الى صفحة ١ لذلك استخدم isActive في اول View

اتوقع اتضحت الفكره NavigationLink الي تستخدم فيه isActive هو الي راح ترجع له

الفكره السابقة بإختصار SwiftUI لما تنقل الى View جديد باستخدم NavigationLink تخلي القيمة isActive = true ولما تضغط زر back تحول القيمة الى false ، فالي سويناه نفس المبدا بحيث نقلنا القيمة باستخدام State و Binding وفي اللحظة اذا اردنا نرجع الى الـ Root حولنا القيمة الى false

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