Adaptive Layout เป็นแนวทางการจัดวางอินเทอร์เฟสที่แอปพลิเคชันใช้เลย์เอาต์ที่แตกต่างกันสำหรับอุปกรณ์ประเภทต่างๆ และการวางแนวหน้าจอที่แตกต่างกัน ซึ่งแตกต่างจาก responsive design ตรงที่ adaptive layout จะสลับระหว่างเลย์เอาต์ที่เตรียมไว้ล่วงหน้าที่จุดเบรกพอยต์ บทความนี้กล่าวถึง Size Classes และ UITraitCollection ใน iOS ตัวระบุ sw600dp/layout-w600dp ใน Android และยกตัวอย่างเชิงปฏิบัติ
ประเด็นสำคัญ
Adaptive Layout เป็นกลยุทธ์ UI ที่นักพัฒนาสร้างเลย์เอาต์แยกต่างหากสำหรับอุปกรณ์แต่ละประเภทและสลับไปมาระหว่างกันตามคุณลักษณะของหน้าจอ ซึ่งแตกต่างจาก responsive design ที่องค์ประกอบต่างๆ ไหลลื่นอย่างต่อเนื่อง adaptive layout ใช้การสลับแบบไม่ต่อเนื่อง: โทรศัพท์ — เลย์เอาต์หนึ่ง แท็บเล็ต — อีกแบบ เดสก์ท็อป — แบบที่สาม
Apple เปิดตัว Size Classes ใน iOS 8 (2014) พร้อมกับแอปพลิเคชันสากล (ไบนารีที่ทำงานทั้งบน iPhone และ iPad) Google เปิดตัวตัวระบุ smallestWidth (sw
คำแนะนำสมัยใหม่ของ Apple HIG และ Google Material Design เห็นพ้องกันว่า adaptive layout เป็นสิ่งจำเป็นสำหรับแอปที่รองรับมากกว่าหนึ่งขนาดหน้าจอ Google Play ทำเครื่องหมายการขาดการปรับเปลี่ยนสำหรับแท็บเล็ตเป็นข้อบกพร่อง App Store ไม่มีข้อกำหนดอย่างเป็นทางการ แต่ผู้ใช้ iPad คาดหวังประสบการณ์แบบเนทีฟ ไม่ใช่แอป iPhone ที่ถูกยืดออก
Size Classes เป็นคุณลักษณะของ iOS ที่กำหนดพื้นที่ว่างเป็น Compact (กะทัดรัด) หรือ Regular (ปกติ) ในแต่ละแกน UITraitCollection เป็นระบบ iOS สำหรับส่งผ่านคุณลักษณะสภาพแวดล้อมผ่านลำดับชั้นของ view: ขนาด มาตราส่วนหน้าจอ ความสามารถ force touch ช่วงสี (สว่าง/มืด) การตั้งค่าการเข้าถึง
| อุปกรณ์ / การวางแนว | แนวนอน | แนวตั้ง |
|---|---|---|
| iPhone แนวตั้ง (ทุกรุ่น) | Compact | Regular |
| iPhone Plus/Pro Max แนวนอน | Regular | Compact |
| iPhone SE / mini แนวนอน | Compact | Compact |
| iPad แนวตั้ง | Regular | Regular |
| iPad แนวนอน | Regular | Regular |
| iPad Split View (1/3 หน้าจอ) | Compact | Regular |
UITraitCollection ถูกสร้างขึ้นโดยระบบและส่งผ่านจาก UIApplication ผ่าน UIWindow ไปยัง UIView แต่ละตัว เมื่อการวางแนวหรือขนาดหน้าต่าง (Split View) เปลี่ยนไป ระบบจะสร้าง UITraitCollection ใหม่และเรียก traitCollectionDidChange ใน SwiftUI การเปลี่ยนแปลงจะถูกติดตามผ่าน Environment Values และ view จะถูกวาดใหม่โดยอัตโนมัติ
การปรับเปลี่ยนผ่าน UITraitCollection ใน UIKit ถูกนำไปใช้โดยการสมัครรับการเปลี่ยนแปลง trait collection และสร้างเลย์เอาต์ใหม่ ตัวอย่างด้านล่างแสดงคอนโทรลเลอร์ที่สลับระหว่างสแต็กแนวตั้งสำหรับ iPhone และเลย์เอาต์แบบแบ่งสำหรับ iPad
// คอนโทรลเลอร์แบบปรับเปลี่ยนได้กับ Size Classes
import UIKit
final class AdaptiveViewController: UIViewController {
private let compactStack = UIStackView()
private let regularStack = UIStackView()
private let sidebar = UIView()
private let content = UIView()
override func viewDidLoad() {
super.viewDidLoad()
setupViews()
updateLayout(for: traitCollection)
}
override func traitCollectionDidChange(
_ previousTraitCollection: UITraitCollection?
) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.horizontalSizeClass
!= previousTraitCollection?.horizontalSizeClass {
updateLayout(for: traitCollection)
}
}
private func updateLayout(for traits: UITraitCollection) {
if traits.horizontalSizeClass == .regular {
showRegularLayout() // iPad: แถบด้านข้าง + เนื้อหา
} else {
showCompactLayout() // iPhone: แถบแท็บ + ผลัก
}
}
private func showRegularLayout() {
view.subviews.forEach { $0.removeFromSuperview() }
regularStack.addArrangedSubview(sidebar)
regularStack.addArrangedSubview(content)
regularStack.frame = view.bounds
regularStack.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(regularStack)
}
private func showCompactLayout() {
view.subviews.forEach { $0.removeFromSuperview() }
compactStack.addArrangedSubview(content)
compactStack.frame = view.bounds
compactStack.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(compactStack)
}
private func setupViews() {
sidebar.backgroundColor = .systemGray6
content.backgroundColor = .systemBackground
compactStack.axis = .vertical
regularStack.axis = .horizontal
}
}เมธอด traitCollectionDidChange ถูกเรียกเมื่อมีการเปลี่ยนแปลง traits ใดๆ แต่เราจะตรวจสอบเฉพาะ horizontalSizeClass — ตัวบ่งชี้ที่สำคัญที่สุดสำหรับการเปลี่ยนเลย์เอาต์ ใน iOS 17+ แนะนำให้ใช้ UIViewController.horizontalSizeClass เป็น computed property สำหรับการอัปเดตแบบรีแอกทีฟโดยไม่ต้องสมัครรับการเปลี่ยนแปลง
Android มีตัวระบุความกว้างหลักสองตัว: sw
| ตัวระบุ | เงื่อนไข | ตัวอย่างอุปกรณ์ |
|---|---|---|
| layout-sw600dp | ความกว้างขั้นต่ำ ≥ 600 dp | iPad (768 dp), Pixel C (900 dp) |
| layout-w600dp | ความกว้างปัจจุบัน ≥ 600 dp | iPad ใน Split View (600 dp), โทรศัพท์ในแนวนอน |
| layout-land | การวางแนวแนวนอน | อุปกรณ์ใดๆ ที่หมุนในแนวนอน |
| layout-sw720dp | ความกว้างขั้นต่ำ ≥ 720 dp | iPad Pro (833 dp), Galaxy Tab S9 (800 dp) |
ลำดับชั้นทรัพยากรใน Android: res/layout/activity_main.xml (โทรศัพท์), res/layout-sw600dp/activity_main.xml (แท็บเล็ต 7\"), res/layout-w600dp-land/activity_main.xml (โทรศัพท์ในแนวนอน) Android เลือกตัวระบุที่เฉพาะเจาะจงที่สุดที่ตรงกับการกำหนดค่าอุปกรณ์ปัจจุบัน เมื่อไม่มีไฟล์ที่ตรงกัน ทรัพยากรพื้นฐานจาก res/layout/ จะถูกใช้
การพัฒนา Android สมัยใหม่ใช้ Jetpack WindowManager เพื่อกำหนดขนาดหน้าต่างแทนการอ่านทรัพยากรโดยตรง ซึ่งสำคัญโดยเฉพาะอย่างยิ่งสำหรับอุปกรณ์พับได้ที่ขนาดหน้าจอเปลี่ยนแปลงแบบไดนามิก (Galaxy Fold กางออกจาก 6.2\" เป็น 7.6\")
// การปรับเปลี่ยนผ่าน Jetpack WindowManager
import androidx.window.core.layout.WindowHeightSizeClass
import androidx.window.core.layout.WindowSizeClass
import androidx.window.core.layout.WindowWidthSizeClass
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AdaptiveScreen() {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
FlowRow(
verticalAlignment = Arrangement.Top,
horizontalArrangement = when (windowSizeClass.windowWidthSizeClass) {
WindowWidthSizeClass.EXPANDED -> Arrangement.SpaceEvenly
else -> Arrangement.Start
}
) {
when (windowSizeClass.windowWidthSizeClass) {
WindowWidthSizeClass.COMPACT -> {
CompactContent()
}
WindowWidthSizeClass.MEDIUM -> {
MediumContent()
}
WindowWidthSizeClass.EXPANDED -> {
ExpandedContent()
}
}
}
}
@Composable
private fun CompactContent() {
Column(modifier = Modifier.padding(16.dp)) {
Text("โทรศัพท์", style = MaterialTheme.typography.headlineSmall)
ListContent()
}
}
@Composable
private fun MediumContent() {
Row(modifier = Modifier.padding(24.dp)) {
NavigationRail { ... }
Column { ListContent() }
}
}
@Composable
private fun ExpandedContent() {
Row(modifier = Modifier.padding(32.dp)) {
PermanentNavigationDrawer { ... }
Column {
ListContent()
}
DetailPane()
}
}คอมโพเนนต์ currentWindowAdaptiveInfo() จากไลบรารี androidx.window ปรับอินเทอร์เฟสให้เข้ากับอุปกรณ์ใดๆ: โทรศัพท์ แท็บเล็ต อุปกรณ์พับได้ ChromeOS คลาสความกว้าง: COMPACT (0–600 dp), MEDIUM (600–840 dp), EXPANDED (>840 dp) คลาสความสูง: COMPACT (0–480 dp), MEDIUM (480–900 dp), EXPANDED (>900 dp)
SwiftUI มีเครื่องมือในตัวสำหรับเลย์เอาต์แบบปรับเปลี่ยนได้โดยไม่ต้องใช้ Size Classes โดยตรง AnyLayout, ViewThatFits และ GeometryReader ช่วยให้สร้างอินเทอร์เฟสที่ปรับให้เข้ากับพื้นที่ว่างโดยอัตโนมัติ
// การปรับเปลี่ยน SwiftUI ผ่าน AnyLayout และ ViewThatFits
import SwiftUI
struct AdaptiveGrid: View {
@Environment(\.horizontalSizeClass) private var hSizeClass
var body: some View {
let layout = hSizeClass == .regular
? AnyLayout(HStackLayout())
: AnyLayout(VStackLayout())
layout {
Label("รายการโปรด", systemImage: "star")
Label("ล่าสุด", systemImage: "clock")
Label("การตั้งค่า", systemImage: "gear")
}
.padding()
}
}
// ViewThatFits — การเลือกเลย์เอาต์อัตโนมัติ
struct SmartLayout: View {
var body: some View {
ViewThatFits {
HStack { // ลำดับความสำคัญ 1: แนวนอน
CardView()
CardView()
}
VStack { // หากไม่พอ — แนวตั้ง
CardView()
CardView()
}
}
}
}ViewThatFits เป็นเครื่องมืออันทรงพลังของ SwiftUI ที่เลือก view ย่อยตัวแรกที่พอดีกับพื้นที่ว่างโดยไม่ถูกตัดออกโดยอัตโนมัติ ซึ่งช่วยลดความจำเป็นในการตรวจสอบ Size Classes อย่างชัดเจนสำหรับการสลับแบบปรับเปลี่ยนได้ง่ายๆ AnyLayout ช่วยให้เปลี่ยนประเภทเลย์เอาต์ (HStack/VStack) ได้โดยไม่ต้องใช้ if-else ในเนื้อหาของ view
Jetpack Compose ใช้ WindowSizeClass (จากไลบรารี Material 3 adaptive) และ BoxWithConstraints สำหรับเลย์เอาต์แบบปรับเปลี่ยนได้ ซึ่งแตกต่างจากตัวระบุ XML Compose จะกำหนดขนาดหน้าต่างในรันไทม์และประกอบ UI ใหม่แบบรีแอกทีฟเมื่อการกำหนดค่าเปลี่ยนแปลง
// Jetpack Compose ปรับเปลี่ยนผ่าน BoxWithConstraints
@Composable
fun AdaptiveList(items: List<String>) {
BoxWithConstraints {
val width = maxWidth
if (width >= 600.dp) {
// แท็บเล็ต: สองคอลัมน์ (list-detail)
Row(modifier = Modifier.fillMaxSize()) {
LazyColumn(modifier = Modifier.weight(1f)) {
items(items) { item ->
ListItem(text = item)
}
}
var selected by remember { mutableStateOf(items.first()) }
DetailPanel(item = selected)
.weight(2f)
}
} else {
// โทรศัพท์: หนึ่งคอลัมน์พร้อมการนำทาง
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items) { item ->
ListItem(text = item, onClick = { navigateToDetail(item) })
}
}
}
}
}
@Composable
fun AdaptiveListMaterial3() {
val windowClass = currentWindowAdaptiveInfo().windowSizeClass
AdaptiveLayout(
layout = windowClass.windowWidthSizeClass
) {
when (windowClass.windowWidthSizeClass) {
WindowWidthSizeClass.COMPACT -> {
ListDetailScaffold(
isDetailOnly = false,
list = { ListPane() },
detail = { DetailPane() }
)
}
else -> {
ListDetailScaffold(
isDetailOnly = false,
list = { ListPane() },
detail = { DetailPane() }
)
}
}
}
}Material 3 Adaptive Layouts มีคอมโพเนนต์พร้อมใช้: ListDetailScaffold, SupportingPaneScaffold, NavigationSuiteScaffold คอมโพเนนต์เหล่านี้ปรับให้เข้ากับ WindowWidthSizeClass โดยอัตโนมัติ — สลับระหว่างแบบซ้อน (COMPACT) และแบบเคียงข้างกัน (MEDIUM/EXPANDED) นักพัฒนาเพียงแค่เลือกรูปแบบและส่งผ่านแผงเนื้อหา
คำถามที่พบบ่อย
sw600dp (smallestWidth) — ความกว้างหน้าจอขั้นต่ำโดยไม่คำนึงถึงการวางแนว ทำงานบนแท็บเล็ต 7\"+ เสมอ w600dp (available width) — ความกว้างที่พร้อมใช้งานโดยพิจารณาจากการวางแนวปัจจุบัน ทำงานบนโทรศัพท์ในแนวนอนที่ความกว้างอาจเกิน 600 dp
UITraitCollection คือออบเจกต์ iOS ที่มีคุณลักษณะสภาพแวดล้อม: size classes (แนวนอน/แนวตั้ง), มาตราส่วนหน้าจอ, ความสามารถ force touch, สำนวนอินเทอร์เฟสผู้ใช้ (iPhone/iPad), ช่วงสี, การตั้งค่าการเข้าถึง มันถูกส่งผ่านจาก UIApplication ผ่านลำดับชั้นของ view และเปลี่ยนแปลงเมื่อการวางแนวหรือขนาดหน้าต่างเปลี่ยนไป
ใช้ Jetpack WindowManager (Android) และ UIScreen nativeBounds + traitCollection (iOS) อุปกรณ์พับได้เปลี่ยน Size Class เมื่อกางออก แนะนำให้ใช้สถาปัตยกรรมบนพื้นฐานของเลย์เอาต์มาตรฐาน (list-detail, supporting pane) พร้อมการสมัครรับการเปลี่ยนแปลงขนาดหน้าต่างแบบรีแอกทีฟโดยไม่ต้องรีสตาร์ท Activity
ไม่ ใช้ storyboard เดียวกับรูปแบบข้อจำกัด Size Class หรือเลย์เอาต์แบบโปรแกรมผ่าน UIKit SwiftUI ไม่ต้องการ storyboard เลย storyboard แยกต่างหากสำหรับ iPad สร้างความซ้ำซ้อนและทำให้การบำรุงรักษาซับซ้อน — ควรปรับเลย์เอาต์เดียวผ่าน Size Classes จะดีกว่า
Canonical layouts คือรูปแบบเลย์เอาต์แบบปรับเปลี่ยนได้พร้อมใช้จาก Google: list-detail (รายการ + รายละเอียด), supporting pane (เนื้อหา + แผงการดำเนินการ), feed (การ์ด) แต่ละรูปแบบรองรับสามสถานะ (compact/medium/expanded) และถูกนำไปใช้ในไลบรารี Material 3 Adaptive ผ่าน ListDetailScaffold และ SupportingPaneScaffold
สรุป
เราจะพัฒนาแอปพลิเคชันบนมือถือแบบครบวงจร
IT Sectr สร้างแอปพลิเคชัน iOS และ Android สำหรับสตาร์ทอัพและธุรกิจตั้งแต่ปี 2017 เราจะให้คำแนะนำและเสนอวิธีแก้ปัญหาที่ดีที่สุดแก่คุณ