درک گردش داده در SwiftUI — راهنمای کاربردی
به دلیل جدید بودن SwiftUI و کمبود مستندات مربوط به آن، اغلب توسعهدهندگان با مشکل طراحی معماری اپلیکیشنها در این فریمورک مواجه هستند. در این مقاله شیوه استفاده از wrapper-های مختلف مشخصهها که از سوی SwiftUI ارائه شده را بررسی میکنیم تا به درک گردش داده در SwiftUI برسیم.
پیشنهاد میکنیم پیش از مطالعه این مقاله نگاهی به مستندات گردش داده در SwiftUI (+) که از سوی اپل ارائه شده است، بیندازید. البته این مستندات از مشکل کمبود مثالهای مناسب رنج میبرد و این همان ضعفی است که قرار است در این مقاله آن را پوشش دهیم.
ObservableObject
ObservableObject (+) پروتکلی است که بخشی از فریمورک Combine (+) به حساب میآید. برای استفاده از آن صرفاً باید پروتکل را به کلاس مدل اضافه کرده و سپس هر مشخصهای که میخواهید از سوی SwiftUI درون این مدل مشاهده شود را به صورت Published@ علامتگذاری کنید.
1final class MoviesSelectedMenuStore: ObservableObject {
2 @Published var menu: MoviesMenu
3
4 init(selectedMenu: MoviesMenu) {
5 self.menu = selectedMenu
6 }
7}
چه زمانی از ObservableObject استفاده کنیم؟
ObservableObject پروتکل مناسبی برای استفاده روی ViewModel محسوب میشود. همچنین در صورتی که نمیخواهید از ViewModel استفاده کنید یا به آن نیاز ندارید، میتوانید از این پروتکل مستقیماً بهره بگیرید. اساساً هر شیئی که نیاز به نگهداری مشخصههایی دارد که مستقیماً در نما استفاده میشوند باید به صورت Published@ درون یک ObservableObject علامتگذاری شده و پوشش یابد. آن را میتوان به صورت یک کلاس مبنای مدل در SwiftUI تصور کرد، گرچه یک پروتکل است و کلاس محسوب نمیشود.
ObservedObject@
چنان که احتمالاً حدس زدهاید، این wrapper مشخصه برای تبدیل کلاسهای ViewModel که با ObservableObject مطابقت دارند مورد استفاده قرار میگیرد. این wrapper شیء مورد نظر را درون یک مشخصه نمای دینامیک قرار میدهد که به SwiftUI امکان میدهد تا در آن شیء مشترک شود و هر زمان که برخی مشخصههای Published@ در مدل تغییر یافتند، نمای بدنه آن را «نامعتبر» (invalidate) سازد.
1struct MoviesHome : View {
2 @ObservedObject private var selectedMenu = MoviesSelectedMenuStore(selectedMenu: .popular)
3
4 var body: some View {
5 NavigationView {
6 Group {
7 if selectedMenu.menu == .genres {
8 GenresList()
9 } else {
10 MoviesHomeList(menu: $selectedMenu.menu)
11 }
12 }
13 }
14 }
15}
چه زمانی از ObservedObject@ استفاده کنیم؟
هر زمان که لازم باشد یک ObservableObject را به «نما» (View) اتصال دهید، میتوانید از این wrapper استفاده کنید. به بیان دیگر هر زمان که لازم باشد نما بسته به تغییرات در این شیء بهروزرسانی شود، میتوان از آن بهره گرفت.
نکته
نماهای SwiftUI از نوع مقداری هستند (چون Struct محسوب میشوند) و در صورتی که نما از سوی نمای والد مجدداً ایجاد شود، شیء را درون دامنه نمای خود نگهداری نمیکنند. بنابراین بهتر است این اشیای observable را «با ارجاع» ارسال کنیم و نوعی نمای کانتینر یا کلاس نگهدارنده داشته باشیم که آن اشیا را وهلهسازی کرده و مورد ارجاع قرار میدهد. اگر نما تنها ارجاع به این شیء باشد و آن نما به دلیل بهروزرسانی نمای والد از سوی SwiftUI بازسازی شود، «حالت» (State) کنونی ObservedObject خودتان را از دست خواهید داد.
State@
State@ (+) یک wrapper برای مشخصه است که در SwiftUI به طور مکرر مورد استفاده قرار میگیرد. این پوشش یک مقدار دائمی (بین رفرش ها ثابت میماند) ایجاد میکند. باید بدانیم چنان که پیشتر گفتیم، نماهای SwiftUI به صورت struct هستند و از نوع مقداری به حساب میآیند، بنابراین SwiftUI میتواند نمای شما را هر زمان و به هر دلیلی از نو بسازد. بر همین اساس همه مشخصهها در نماها به صورت تغییرناپذیر طراحی شدهاند و هر زمان که نما بازسازی میشود از نو ساخته میشوند. دلیل این مسئله میتواند به سادگی این باشد که نمای والد چنین تصمیم گرفته است. آن را میتوانید به صورت حالت نمای لوکال ببینید.
بنابراین اگر بخواهید یک مقدار لوکال برای این نما بسازید، که دائمی باشد و بتوانید تغییر دهید، باید از wrapper مشخصه State@ استفاده کنید. مزیت افزوده آن این است که SwiftUI نیز در آن مشترک است و بخش مرتبط با نما را هر زمان که تغییر مییابد از سوی نما و یا به وسیله اتصال نامعتبر کرده و یا رفرش میکند.
چنان که در قطعه کد زیر میبینید، TabView که کامپوننت Tabbar در SwiftUI است، یک اتصال میگیرد و میتوانید یک اتصال از مشخصه State@ با استفاده از $ به دست آورید. در بخش بعدی در مورد این اتصال بیشتر صحبت خواهیم کرد.
1struct TabbarView: View {
2 @State var selectedTab = Tab.movies
3
4 enum Tab: Int {
5 case movies, discover, fanClub, myLists
6 }
7
8 func tabbarItem(text: String, image: String) -> some View {
9 VStack {
10 Image(systemName: image)
11 .imageScale(.large)
12 Text(text)
13 }
14 }
15
16 var body: some View {
17 TabView(selection: $selectedTab) {
18 MoviesHome().tabItem{
19 self.tabbarItem(text: "Movies", image: "film")
20 }.tag(Tab.movies)
21 DiscoverView().tabItem{
22 self.tabbarItem(text: "Discover", image: "square.stack")
23 }.tag(Tab.discover)
24 FanClubHome().tabItem{
25 self.tabbarItem(text: "Fan Club", image: "star.circle.fill")
26 }.tag(Tab.fanClub)
27 MyLists().tabItem{
28 self.tabbarItem(text: "My Lists", image: "heart.circle")
29 }.tag(Tab.myLists)
30 }
31 .edgesIgnoringSafeArea(.top)
32 }
33}
چه زمانی از State@ استفاده کنیم؟
هر زمان که نیاز دارید یک حالت مرتبط با نما را به صورت دائمی ذخیره کنید. باید از wrapper مشخصه State@ به عنوان یک داده محلی مختص نما استفاده کنید که باید به صورت دائمی باشد. این مورد میتواند زبانه انتخاب شده Tabbar یا مقدار متن یک TextField و یا یک مقدار UIImage برای یک نمای Image باشد. ضمناً مقدار Bool کنترل میکند که آیا یک sheet ،.popover. یا .actionSheet ارائه شده است یا نه.
Binding@
wrapper مشخصه Binding@ (+) روشی برای ایجاد یک اتصال دو طرفه به مقداری است که از سوی بخش دیگری مدیریت میشود. به احتمال زیاد این چیز دیگر یک State@ از نمای والد است و این رایجترین روش برای استفاده از Binding@ محسوب میشود.
1struct NotificationBadge : View {
2 let text: String
3 let color: Color
4 @Binding var isShown: Bool
5
6 var animation: Animation {
7 Animation
8 .spring()
9 .speed(2)
10 }
11
12 var body: some View {
13 Text(text)
14 .foregroundColor(.white)
15 .padding()
16 .background(color)
17 .cornerRadius(8)
18 .scaleEffect(isShown ? 1: 0.5)
19 .opacity(isShown ? 1 : 0)
20 .animation(animation)
21 }
22}
در کد فوق، میتوانید کامپوننت NotificationBadge را ببینید که وقتی کاربر عملی انجام میدهد، به شکل یک پیام toast چند ثانیه در بخش انتهایی صفحه ظاهر میشود.
مشخصه Bool به نام isShown از طریق اتصال (binding) ارسال میشود. این بدان معنی است که وقتی یک نمای NotificationBadge میسازید باید یک مقدار Bool ارسال کنید که از سوی State@ کنترل میشود. شما میتوانید با استفاده از $ یک Binding از یک حالت بسازید. همچنین میتوانید یک Binding را به صورت دستی ایجاد کنید یا این که یک Binding را از یک مشخصه ObservableObject @Published بسازید.
همچنین میتوانید یک مقدار binding را تغییر دهید. برای نمونه میتوانیم از درون کامپوننت NotificationBadge با استفاده از isShow.value = false مقدار را به False تغییر دهیم. بدین ترتیب State تغییر مییابد و از این رو هم NotificationBadge و هم حالت نمای والد تغییر مییابند.
1struct MovieDetail: View {
2 @State var isAddedToListNotificationShown = false
3
4 func displaySavedBadge() {
5 isAddedToListNotificationShown = true
6 DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
7 self.isAddedToListNotificationShown = false
8 }
9 }
10
11 var body: some View {
12 NotificationBadge(text: "Added successfully",
13 color: .blue,
14 show: $isAddedToListNotificationShown)
15 }
16}
چه زمانی از Binding@ استفاده کنیم؟
مثال فوق، مثال خوبی محسوب میشود. Binding غالباً برای ارسال یک مقدار کنترلشده و دائمی از سوی یک نمای والد مورد استفاده قرار میگیرد و برای نامعتبر ساختن نما در مواردی که مقداری تغییر مییابد استفاده میشود. Binding ابزار خوبی برای TextField, Toggle، کامپوننتهای سفارشی و غیره محسوب میشود. میتوانید تغییرات را منتشر کرده و با ارسال State@ به صورت binding روابطی در سلسله مراتب پیچیده نما ایجاد کنید و همزمان تضمین نمایید که تنها یک نما مقدار آن را به صورت دائمی ذخیره میکند.
ایجاد دستی یک Binding
میتوانیم خودمان نیز یک Binding<Value> ایجاد کنیم. اپل یک متد بسیار راحت init به این منظور ارائه کرده است. توانایی این قابلیت آن است که میتوانید وقتی مقدار تعیین شد و آن را از حافظه مورد نظرتان خواندید، اقدامات و کارهای جانبی دلخواهتان را بر اساس آن اجرا کنید. در این مورد از آن به طور عمده برای اتصال یک مقدار ذخیرهشده در استور ریداکس AppState و انتشار اکشن مطلوب در زمان تعیین شدن آن استفاده میکنیم. به مثال زیر به عنوان منوی زمینهای دقت کنید:
1struct PeopleContextMenu: ConnectedView {
2 struct Props {
3 let isInFanClub: Binding<Bool>
4 }
5
6 let people: Int
7
8 func map(state: AppState, dispatch: @escaping DispatchFunction) -> Props {
9 let isInFanClub = Binding<Bool>(
10 get: { state.peoplesState.fanClub.contains(self.people) },
11 set: {
12 if !$0 {
13 dispatch(PeopleActions.RemoveFromFanClub(people: self.people))
14 } else {
15 dispatch(PeopleActions.AddToFanClub(people: self.people))
16 }
17 })
18 return Props(isInFanClub: isInFanClub)
19 }
20
21 func body(props: Props) -> some View {
22 VStack {
23 Button(action: {
24 props.isInFanClub.value.toggle()
25 }) {
26 HStack {
27 Text(props.isInFanClub.value ? "Remove from fan club" : "Add to fan club")
28 Image(systemName: props.isInFanClub.value ? "star.circle.fill" : "star.circle").imageScale(.medium)
29 }
30 }
31 }
32 }
33}
چنان که میبینید، binding را در اکشن Button تغییر میدهیم که متعاقباً یک اکشن را به استور ما ارسال میکند و مقدار binding را به مقدار بولی مطلوب عوض میکند و در نهایت نمای ما بهروزرسانی میشود. چنان که میبینید مثال ساده و تمیزی است.
EnvironmentObject@
EnvironmentObject@ یک پوشش مشخصه است که در موارد ارائه یک ()environmentObject. به هر والد نمای کنونی در سلسله مراتب دیده میشود. این شیء ارائه شده باید با ObservableObject مطابقت داشته باشد و اگر همچون کد نمونه زیر، نمای ریشه اپلیکیشن عرضه شده باشد، در همه نماهای اپلیکیشن در دسترسی قرار خواهد گرفت. اگر بخواهید شیئی داشته باشید که در کل چرخه عمر اپلیکیشن در دسترس باشد، این ابزاری قدرتمند محسوب میشود.
در مورد الگوی ریداکس که استفاده کردهایم، کل استور که شامل AppState میشود را تزریق نمودهایم و لذا میتوانیم در هر نمایی به آن دسترسی پیدا کنیم.
1class SceneDelegate: UIResponder, UIWindowSceneDelegate {
2
3 var window: UIWindow?
4
5 func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
6 if let windowScene = scene as? UIWindowScene {
7 let window = UIWindow(windowScene: windowScene)
8
9 let controller = UIHostingController(rootView: HomeView().environmentObject(store))
10
11 window.rootViewController = controller
12 window.tintColor = UIColor(named: "steam_gold")
13 self.window = window
14 window.makeKeyAndVisible()
15 }
16 }
17}
18
19
20let store = Store<AppState>(reducer: appStateReducer,
21 middleware: [loggingMiddleware],
22 state: AppState())
سپس در نمای خود میتوانیم از wrapper مشخصه EnvironmentObject@ استفاده کنیم. این نما در این پوشش مشخصه مشترک میشود و محتوایش را بر اساس دادههایی که از شیء میگیرد بهروزرسانی خواهد کرد.
1struct GenresList: View {
2 @EnvironmentObject private var store: Store<AppState>
3 var body: some View {
4 List {
5 ForEach(store.state.moviesState.genres) { genre in
6 NavigationLink(destination: MoviesGenreList(genre: genre)) {
7 Text(genre.name)
8 }
9 }
10 }
11 .navigationBarTitle("Genres")
12 .onAppear {
13 self.store.dispatch(action: MoviesActions.FetchGenres())
14 }
15 }
16}
در کد فوق، از یک wrapper مشخصه استفاده کردهایم و از این رو نمای ما به EnvironmentObject که در نمای ریشه تزریقشده دسترسی دارد و در آن مشترک شده است.
چه زمانی از EnvironmentObject@ استفاده کنیم؟
مثال فوق به خوبی موارد مناسب برای استفاده از این wrapper مشخصه را نمایش میدهد. استور ما مدلها و دادههای ضروری برای اپلیکیشن را نگهداری میکند و از این رو بهتر است همواره تزریق شده و در دسترسمان باشد. نماهایمان نیز زمانی که این مشخصه پوشش تغییر یابد، بهروزرسانی خواهند شد. در واقع این یک سیستم تزریق وابستگی است و از این رو ابزاری قدرتمند برای پیشنمایش و دیباگ محسوب میشود. برای نمونه ما یک استور نمونه را تزریق میکنیم و از این رو میتوانیم دادهها را بدون ارسال درخواست شبکه شبیهسازی کنیم.
همچنین میتوانیم مقادیر سفارشی را برای UI تزریق کنیم که میتواند برای نمونه شامل یک پالت رنگی دینامیک باشد. همچنین از آن میتوان به عنوان ابزار مدیریت پایگاه داده استفاده کرد که نتایج را در اشیایی که در مشخصههای Published@ قرار دارند منتشر میکند. احتمالهای مختلفی برای استفاده از EnvironmentObject وجود دارند. اگر با کتابخانههای تزریق وابستگی آشنا باشید، میدانید که سوئیفت به این ترتیب درهای زیادی به روی شما گشوده است.
اگر چنین آشنایی ندارید، این پوشش مشخصه محل مناسبی برای ذخیره دائمی مدلهایی است که در سراسر اپلیکیشن مورد نیاز هستند و بسیار بهتر از استفاده از یک سینگلتون یا متغیر سراسری است.
سخن پایانی
امیدواریم این مقاله به شما کمک کرده باشد تا اطلاعاتی در مورد گردش داده در SwiftUI به دست آورید. با این که UIKiit در این موضوع کار خارقالعادهای انجام نمیدهد، SwiftUI ابزارهای زیادی ارائه کرده است و در واقع میتوان گفت که مناسبتر از UIKiit است. درک این مفاهیم کمی دشوار است، اما زمانی که به این مفاهیم مسلط شوید، اپلیکیشنهایتان نماهای خود را به روشی معجزه گونه بهروزرسانی میکنند.
این ابزارهای گردش داده در SwiftUI به توسعهدهندگان اجازه میدهند که زمان بسیار کمتری را صرف معماری لایههای مدل خود بکنند و زمان بیشتری برای کار با UI داشته باشند. بدین ترتیب میتوانید کد اسپاگتی (کد نامنظم و بههمریخته)، binding دستی، نوتیفیکیشن، delegate، دستگیرههای تکمیل کلوژر و مواردی از این دست را حذف کنید و از ابزارهای مناسبتری بهره بگیرید.
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای برنامهنویسی
- مجموعه آموزشهای دروس علوم و مهندسی کامپیوتر
- ساخت اپلیکیشن چت برای iOS با SwiftUI — از صفر تا صد
- آشنایی با Binding@ در SwiftUI — به زبان ساده
- ViewModifier سفارشی در SwiftUI — از صفر تا صد
==