มาทำ Animation ด้วย ObjectAnimator บน Android แล้วมาขิง iOS กันเถอะ
บล็อกนี้จริงๆไม่ได้อยู่ในแพลนที่จะเขียนนะ แต่เกิดมาจากความที่ iOS เขามาขิงเราล้วนๆ
คือ มีอยู่วันนึงฝั่ง iOS เขาขิงพร้อมโชว์ให้ดูว่า เขาสร้าง Animation ใน SpriteView ซึ่งสามารถปรับอะไรต่างๆได้ผ่าน Xcode ได้เลย โดยไม่ต้องเขียนโค้ด จริงๆเจ้าตัวนี้ไว้ทำ animation ในเกมส์ แต่ก็ประยุกต์ใช้กับแอพได้ด้วยเช่นกัน
เราในฐาแนะ เอ้ยยย ฐานะ Android Developer รู้สึกว่าทำไม Android Studio ไม่มีบ้างนะ ทำไมนะ ทำไมกัน และมีอะไรขิงคืนได้บ้างนะ
น้องในทีม Android ของเราไม่ยอมเช่นกัน พยายามหาตัวที่คล้ายกันให้มากที่สุด เจอตัวนี้เข้า แต่โชคร้ายที่เขาเลิกทำ library ตัวนี้ไปแล้ว เราจะไปทำต่อก็ยังไงอยู่เนอะ 555
เลยไปตั้งกระทู้ถามใน Thailand Android Developer ว่ามีอะไรคล้ายกันไหม ได้คำตอบมาว่าที่ใกล้เคียงสุดก็คือ OpenGL ES ผู้ซึ่งทำได้ทั้ง 2D และ 3D
เลยลองเขียนดูตาม Tutorial ใน Android Developer มา
https://developer.android.com/training/graphics/opengl
ตัวโครงสร้างจะเป็นแบบนี้
Activity → GLSurfaceView → GLRenderer → Triangle, Square
ตัว Activity จะมีเจ้า GLSurfaceView ไปแปะ แล้วมี GLRenderer ที่มีรูปสามเหลี่ยมที่วาดอีกทีนึง
ในนั้นจะเล่าว่าแต่ละตัวคืออะไร ต้องปรับอะไรตรงไหน มี animation นิดหน่อยด้วยว่าใช้นิ้วจิ้มให้หมุนไปมุมไหน
อันนี้เป็น Document ของ GLSurfaceView เนอะ
https://developer.android.com/reference/kotlin/android/opengl/GLSurfaceView
https://developer.android.com/guide/topics/graphics/opengl
ด้วยตัว SpriteView มีพื้นฐานเป็น OpenGL อยู่แล้ว แต่เนื้อหาเจ้า OpenGL มันเยอะจนเป็นวิชาเรียนหนึ่งวิชาในมหาวิทยาลัยได้เลย แล้วเราจะไป research เพิ่มทำไมกันเล่า เนื้อหาอื่นๆที่ต้องศึกษาก็เยอะจนงง แน่นอน learning curve ก็สูงด้วย
กลับมาที่ตัวงานของเรา ต้องการอะไรกันนะ?
งานของเราต้องการแค่กดปุ่ม แล้วมีรูปภาพวิ่งออกมาจากตำแหน่งของปุ่มมุมขวามือไปตรงกลางจอ โดยเริ่มต้นขนาด 50% และพอถึง location ที่สิ้นสุด ขนาดจะเพิ่มเป็น 100%
ถ้าอย่างงั้นมีตัวไหนบ้างนะที่ทำได้ใน Android?
Object Animator ไง ในบล็อกคุณเอกที่นอนน้อยๆอ่ะ
ซึ่งเขาก็เขียนไว้ครบถ้วนสมบูรณ์แล้ว งั้นจบบล็อกนี้แล้วกันเนอะ
ได้หรอม? ไม่ได้สิ
ตัว Android Developer ในส่วน Training จะเป็นอย่างนี้นะ อันนี้แปะไว้เฉยๆอ่ะ
https://developer.android.com/training/animation
หรือจะเรียนผ่าน codelab ก็ได้น้า
โดย codelab ที่เราจะเริ่มศึกษา Object Animator ก็จะมี 2 codelab ด้วยกัน คือ
- Property Animation ตัวอย่างเป็นแอพสร้างน้องดาวขึ้นมา มี control position, size, rotation, and translucency ในบล็อกนี้พูดถึงตัวนี้
- Animation with MotionLayout อันนี้ของใหม่ ยกไปบล็อกต่อๆๆๆๆๆๆไปดีกว่า
ทั้งสองจะอยู่ในชุดของ Advanced Android in Kotlin นะ
ทำความรู้จักกับ ObjectAnimator กันเถอะ
ก่อนอื่นมาทำความรู้จักกันก่อนว่า ObjectAnimator คืออะไร?
ObjectAnimator
เป็น sub-class ของ ValueAnimator
ที่ support การแสดง animation ใน view ที่เราต้องการ สามารถสร้างได้แบบ resource file ที่เป็น xml หรือเขียนด้วย code ก็ได้นะ ซึ่งเราจะกล่าวกันในบล็อกนี้เนอะ
https://developer.android.com/reference/android/animation/ObjectAnimator
หลักการคร่าวๆในการทำ animation แบบเบื้องต้น (ยังไม่ใช่ท่า advance)
จะมีประมาณ 3 ขั้นตอน คือ
1) สร้าง ObjectAnimator ขึ้นมา ว่าให้ view นี้ animate อย่างไร เช่น ให้ view ที่เป็นรูปดาวหมุน
val animator = ObjectAnimator.ofFloat(star, View.ROTATION, -360f, 0f)
target
ใส่ view ที่ต้องการให้มัน animateproperty
จะให้ view นั้น animate ไปแบบไหนvalues
จะให้ทำไปเท่าไหร่ ถ้าตัวอย่างก็คือหมุนจาก -360 ไป 0 องศา คือหมุนตามเข็มนาฬิกานั่นแหละ
2) setting เจ้า ObjectAnimator
ที่เราเพิ่งสร้างไปทำอะไรเพิ่ม เช่น
มันจะมีบางเคส เช่น ให้ขยับด้านขวาไป 200 นะ แล้วช่วยกลับมาหน่อย โดยให้ repeat รอบนึง และให้ reverse จากเดิม
animator.repeatCount = 1
animator.repeatMode = ObjectAnimator.REVERSE
เราสามารถกำหนดเวลาการแสดงผลได้ดังนี้
animator.duration = 500
ระหว่างที่แสดง animation จะให้ปุ่มไม่สามารถกดได้เมื่อ animation แสดง เพื่อไม่ให้แสดง animation ที่ไม่ต่อเนื่องออกไป
private fun ObjectAnimator.disableViewDuringAnimation(view: View) {
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationStart(animation: Animator?) {
view.isEnabled = false
}
override fun onAnimationEnd(animation: Animator?) {
view.isEnabled = true
}
})
}
สามารถเอาไปใช้ได้ดังนี้
animator.disableViewDuringAnimation(rotateButton)
3) แสดง animation ขึ้นมาได้เล้ยยย
animator.start()
Animation ที่ทำใน codelab จะมีดังนี้
- ROTATE หมุนวัตถุ ในที่นี้ก็กล่าวถึงไปข้างต้นแล้ว ว่าให้หมุนดาวเป็นตามเข็มนาฬิกา
val animator = ObjectAnimator.ofFloat(star, View.ROTATION, -360f, 0f)
animator.duration = 1000
animator.disableViewDuringAnimation(rotateButton)
animator.start()
- TRANSLATE ย้ายที่วัตถุไปตรงไหน
ตัวอย่างใน codelab จะบอกว่าให้เลื่อนไปทางขวา 200 นะ จากนั้นขอ repeat รอบนึงโดยรอบนี้ช่วย reverse มานะ ตามที่เพิ่งกล่าวไป
val animator = ObjectAnimator.ofFloat(star, View.TRANSLATION_X, 200f)
animator.repeatCount = 1
animator.repeatMode = ObjectAnimator.REVERSE
animator.disableViewDuringAnimation(translateButton)
animator.start()
และเราลองทำเพิ่มว่าถ้าเลื่อนไปจากเดิมไปทางเฉียงหล่ะ จากการทดสอบโดยการเปลี่ยนค่าในการ translation ทั้งแกน x และ y จะได้ข้อสรุปแบบนี้
ถ้าเราอยากให้ไปทางเฉียงๆนั้น จะต้องกำหนดแกน x ค่าเป็นลบ และแกน y ค่าเป็นลบเช่นกัน และ view นี้จะใช้ property 2 ตัว คือ TRANSLATION_X
และ TRANSLATION_Y
แต่การสร้าง ObjectAnimator
มันใส่ property ได้อันเดียวนี่นา ทำไงดีน้าาา?
ใช้ PropertyValuesHolder
ในการเพิ่ม property ของ animation ว่ามี value เท่าไหร่
val transitionX = PropertyValuesHolder.ofFloat(View.TRANSLATION_X, -200f)
val transitionY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -200f)
พอได้มาหลายๆอันก็เอามาสร้าง ObjectAnimator
val animator = ObjectAnimator.ofPropertyValuesHolder(star, transitionX, transitionY)
target
เหมือนเดิมเลย ใส่ view ที่ต้องการให้มัน animatevalues
ใส่PropertyValuesHolder
ที่เราสร้างมาเมื่อกี้
จากนั้นใส่ของเพิ่มคล้ายๆเดิม
animator.repeatCount = 1
animator.repeatMode = ObjectAnimator.REVERSE
animator.disableViewDuringAnimation(translateButton)
animator.start()
- SCALE ขยายขนาดของวัตถุ
ในที่นี้คือ ช่วยขยายใหญ่เป็น 4 เท่าจากของเดิมให้หน่อย แล้วช่วย reverse กลับมาให้หน่อย
แน่นอนว่ามันควร scale ทั้งแกน x และ y เท่ากัน ถ้าไม่เท่ามันจะมีด้านนึงยืด มันจะแปลกๆเนอะ
ส่วนการย่อขนาดลงไม่รู้ว่าต้องทำยังไง เพราะพอใส่ค่าเป็นลบแล้วมันขยายแต่แกนมันแปลกๆนะ
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 4f)
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 4f)
val animator = ObjectAnimator.ofPropertyValuesHolder(star, scaleX, scaleY)
animator.repeatCount = 1
animator.repeatMode = ObjectAnimator.REVERSE
animator.disableViewDuringAnimation(scaleButton)
animator.start()
- FADE ทำให้วัถตุจางหายหรือเข้มขึ้น
ในที่นี้ให้จางลงจนมองไม่เห็น แล้วช่วย reverse กลับมาให้หน่อย อันนี้ไม่ค่อยมีอะไร
val animator = ObjectAnimator.ofFloat(star, View.ALPHA, 0f)
animator.repeatCount = 1
animator.repeatMode = ObjectAnimator.REVERSE
animator.disableViewDuringAnimation(fadeButton)
animator.start()
- BACKGROUND COLOR เปลี่ยนสีพื้นหลังก็ทำได้ด้วยนะ
โดย target
view ที่เราจะใช้คือ parent ของ ImageView รูปดาวนั่นเอง และใช้ propertyName
ที่ชื่อว่า "backgroundColor"
โดย values
สีเริ่มต้นจากสีดำ ไปสีแดง
val animator = ObjectAnimator.ofInt(star.parent, "backgroundColor", Color.BLACK, Color.RED)
ผลที่ได้ก็คือสีมันไม่ smooth มันจะมีอาการกระตุก จึงต้องใช้ ofArgb แทน โดยตัวนี้นั้น minSdk 21
val animator = ObjectAnimator.ofArgb(star.parent, "backgroundColor", Color.BLACK, Color.RED)
animator.duration = 500
animator.repeatCount = 1
animator.repeatMode = ObjectAnimator.REVERSE
animator.disableViewDuringAnimation(colorizeButton)
animator.start()
และการเปลี่ยนพื้นหลังจะไม่กระตุกอีกต่อไป
ต่อมาเป็นแบบ Advance
- SHOWER ในตัวอย่างทำเป็นน้องดาวตกลงมาจากฟากฟ้าหลายๆดวง
Step 1: A star is born
set ขนาดพ่อแม่น้องดาวก่อน
val container = star.parent as ViewGroup
val containerW = container.width
val containerH = container.height
var starW: Float = star.width.toFloat()
var starH: Float = star.height.toFloat()
จากนั้นสร้างดาว โดยการสร้าง ImageView ขึ้นมาอันนึง แล้วใส่รูปดาวลงไปในนั้น และกำหนด layoutParams
ให้เป็น wrap_content
ทั้งด้าน width
และ height
แล้วเพิ่มไปที่ parent view
val newStar = AppCompatImageView(this)
newStar.setImageResource(R.drawable.ic_star)
newStar.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT)
container.addView(newStar)
Step 2: Size and position the star
random ขนาดน้องดาวที่เพิ่งสร้าง และ set scale ให้เท่ากับทั้งความกว้างและความสูง
newStar.scaleX = Math.random().toFloat() * 1.5f + .1f
newStar.scaleY = newStar.scaleX
starW *= newStar.scaleX
starH *= newStar.scaleY
และ random ตำแหน่งในแนวแกน x เพื่อวางน้องดาว
newStar.translationX = Math.random().toFloat() * containerW - starW / 2
Step 3: Create animators to for star rotation and falling
สร้าง animators ให้น้องดาวตกและหมุนลงไป
val mover = ObjectAnimator.ofFloat(newStar, View.TRANSLATION_Y, -starH, containerH + starH)
mover.interpolator = AccelerateInterpolator(1f)
val rotator = ObjectAnimator.ofFloat(newStar, View.ROTATION, (Math.random() * 1080).toFloat())
rotator.interpolator = LinearInterpolator()
AccelerateInterpolator
"interpolator" that you are setting on the star causes a gentle acceleration motionLinearInterpolator
ตกลงมาแบบ linear แบบบนลงล่าง
Step 4: Run the animations in parallel with AnimatorSet
เมื่อกี้เราสร้าง animator ไป 2 ตัว คือ ย้ายตำแหน่งแกน y ลงมา พร้อมกับการหมุนดาว ซึ่งจะทำพร้อมกัน หลังจากสร้างแล้ว setting animator แล้ว เราสร้าง AnimatorSet()
ขึ้นมา และใส่ animator ที่สร้างไว้เมื่อกี้ ใน playTogether
แล้วก็ random ระยะเวลาการแสดง animator set นี้
val set = AnimatorSet()
set.playTogether(mover, rotator)
set.duration = (Math.random() * 1500 + 500).toLong()
และเมื่อเล่นเสร็จแล้ว ช่วยลบ view น้องดาวที่สร้างออกไปนะ
set.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
container.removeView(newStar)
}
})
พร้อมแล้ว เริ่มกันเลยจ้า
set.start()
ผลที่ได้จะเป็นดังนี้
สรุปโค้ดแบบยาวๆในการสร้างดาวตกแบบหมุนๆ
ปล. ถ้าเราใช้เจ้า ObjectAnimator ในจำนวนที่มากจนเกินไปจะทำให้ animation view ของเรานั้นเกิดอาการหน่วงได้จ้า
ถ้าลองเปลี่ยนเป็นหิมะตกหล่ะ
Step 1: A star is born
ขั้นตอนแรกยังคงเดิม เปลี่ยนจากการสร้างน้องดาวน้อยมาเป็นหิมะแทน
val container = star.parent as ViewGroup
val containerW = container.width
val containerH = container.height
var starW: Float = star.width.toFloat()
var starH: Float = star.height.toFloat()
val newSnow = AppCompatImageView(this)
newSnow.setImageResource(R.drawable.ic_snow)
newSnow.alpha = Math.random().toFloat()
newSnow.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT)
container.addView(newSnow)
สิ่งที่ต่างไป แน่นอนสิ่งแรก drawable ที่ต่างกัน หิมะก็ drawable สีขาวกลมๆนั่นแหละ
newSnow.setImageResource(R.drawable.ic_snow)
และเราต้องการให้ random ตัว alpha หน่อยเนอะ ซึ่งค่า alpha จะมี range ระหว่าง 0 - 1 จ้า
newSnow.alpha = Math.random().toFloat()
Step 2: Size and position the star ส่วน size และ position ก็ลอกของเดิมมา
Step 3: Create animators to for star rotation and falling
เราจะให้หิมะตกลงมา แล้วให้มันเลี้ยวๆเบ้ๆ ไม่ให้ตกลงมาตรงๆ จะให้ตกแบบเฉียงๆนิดๆ แบบโค้งหน่อยๆ
val directionX = ((Math.random() * 2 - 1) * 100).toFloat()
val moverX = ObjectAnimator.ofFloat(newSnow, View.TRANSLATION_X, newSnow.translationX, newSnow.translationX + directionX)
moverX.interpolator = AccelerateInterpolator(1f)
val moverY = ObjectAnimator.ofFloat(newSnow, View.TRANSLATION_Y, -starH, containerH + starH)
moverY.interpolator = LinearInterpolator()
แล้วเราจะกำหนด เบ้ซ้าย เบ้ขวายังไง จากก่อนหน้านี้ที่สรุปเรื่องแกนไว้ให้ ถ้าไปทางซ้ายค่าเป็นลบ และไปทางขวาค่าเป็นบวก ดังนั้นเราจะ random ค่าระหว่าง 0 ถึง 2 ออกมา แล้วหักไป 1 เพื่อนเลื่อนแกน เราจะได้ค่าเป็นลบออกมาถ้ามันได้ค่าน้อยกว่า 1 แล้วเอาไปคูณ 100 เข้าไปโดยไม่มีเหตุผล
เดี๋ยวสิ
เพราะเราลองแล้วพบว่าถ้าไม่ขยายค่าเข้าไป แบบจะมองไม่ออกว่ามันเบ้ไปข้างนึงไหม เลยคูณ 100 เข้าไป ดังนั้นจะเบ้ซ้ายขวาระหว่าง 0 ถึง 100 นั่นเอง จะได้เห็นกันชัดๆ
Step 4: Run the animations in parallel with AnimatorSet
จับการเคลื่อนไหวทั้งสองแกนมัดรวมให้แสดงพร้อมกัน จะได้ลงมาเฉียงๆหน่อย
val set = AnimatorSet()
set.playTogether(moverX, moverY)
set.duration = (Math.random() * 2500 + 1000).toLong()
set.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
container.removeView(newSnow)
}
})
set.start()
ผลที่ได้ขอแบบกดรัวๆ
แน่นอนว่าแบ่งแบบนี้มันไม่สวยดูยาก งั้นจัด gist ให้เลยจ้า ยาวไปยาวไป
มาประยุกต์ใช้กับงานของเรากันเถอะ
กลับมาที่เรื่องงาน เราต้องการให้รูปภาพของเรา เคลื่อนจากปุ่มที่อยู่ล่างขวา และเลื่อนเฉียงไปในแนวแกน x ที่กึ่งกลาง และแกน y ลงจากขอบจอมาประมาณนึง และพอถึง location ที่สิ้นสุด ขนาดจะเพิ่มเป็น 2 เท่าจากเดิม
ก่อนอื่นสร้างน้องดาวกันก่อนเช่นเคย
val newStar = AppCompatImageView(this)
newStar.setImageResource(R.drawable.ic_star)
newStar.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT)
ต่อมาพ่อแม่น้องดาวเช่นเคย แล้วเอาน้องดาวไปให้ parent view ซะ
val container = star.parent as ViewGroup
val containerW = container.width
val containerH = container.height
container.addView(newStar)
จากนั้นวางตำแหน่งน้องดาวไว้มุมล่างขวา คนกดมาอ่านและทำตามสามารถปรับตำแหน่งได้ตามใจชอบนะ คือ -150f เนี่ยยย ไม่มีนัยนะอะไรทั้งสิ้น ใส่มั่วไปก่อน แบบไม่อยากให้ติดมุมเกินไป
newStar.translationX = containerW - 150f
newStar.translationY = containerH - 150f
action แรกของเราก็คือ เคลื่อนน้องดาวไปตรงกลางจอในแนวแกน x และลงจากขอบจอมาหน่อย ตัวเลข 300f ไม่มีเหตุผลอีกหล่ะ รบกวนไปปรับกันเองตามใจชอบแล้วกันเน้อ
val transitionX = PropertyValuesHolder.ofFloat(View.TRANSLATION_X, (containerW / 2).toFloat())
val transitionY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 300f)
val moveAnimator = ObjectAnimator.ofPropertyValuesHolder(newStar, transitionX, transitionY)
ต่อมา พอถึงตำแหน่งที่เราต้องการแล้วไซร์ ก็ให้น้องดาวนั้นใหญ่เป็น 2 เท่าไปเล้ยยยย
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 2f)
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 2f)
val scaleAnimator = ObjectAnimator.ofPropertyValuesHolder(newStar, scaleX, scaleY)
scaleAnimator.duration = 500
เนื่องจาก animator ของเรานั้น มันจะเริ่มจาก 1 ไป 2 มันจะไม่พร้อมกันเหมือนน้องดาวตก ดังนั้นเราจะสร้าง AnimatorSet()
ขึ้นมา และใส่ animator ที่สร้างไว้เมื่อกี้ ใน playSequentially
เพื่อให้เล่นตามลำดับที่เราวางไว้ แน่นอนเล่นเสร็จช่วยออกไปด้วยนะน้องดาว
val set = AnimatorSet()
set.playSequentially(moveAnimator, scaleAnimator)
set.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
container.removeView(newStar)
}
})
set.start()
เท่านี้ก็เรียบร้อยแล้ว ในทีนี้จะไม่ disable button เมื่อ animate ยังไม่เสร็จน้าาา จะให้กดรัวๆได้ โดยน้องดาวจะสร้างใหม่และดับไปเนอะ
แล้วฝั่ง iOS เดินมาถามอีกว่า มีแบบดึ๋งๆไหม โดย scale จะเป็น 0 → 1.5 เท่า → 1.2 เท่า ในระยะเวลาทั้งหมด 200 ms อะเครจัดปายยย
step แรกเลยคือ จาก 0 จะค่อยขยายรูปถึง 1.5 เท่า
และ step ถัดมาคือ ลดขนาดลงเป็น 1.2 เท่า
val scaleX1 = PropertyValuesHolder.ofFloat(View.SCALE_X, 0f, 1.5f)
val scaleY1 = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0f, 1.5f)
val animator1 = ObjectAnimator.ofPropertyValuesHolder(star, scaleX1, scaleY1)
val scaleX2 = PropertyValuesHolder.ofFloat(View.SCALE_X, 1.5f, 1.2f)
val scaleY2 = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1.5f, 1.2f)
val animator2 = ObjectAnimator.ofPropertyValuesHolder(star, scaleX2, scaleY2)
val set = AnimatorSet()
set.playSequentially(animator1, animator2)
set.duration = 200
set.start()
ตอนแรกลองใส่ duration
แยกระหว่าง animator แต่ละตัว พบว่าไม่รอดจ้า มองแทบไม่ทัน พอมาใส่ใน set
พบว่ามันดีกว่าและ smooth กว่ามากเลยแหะ
ปัญหาที่เกิดขึ้นในงานจริงคือ การแสดง animation มากกว่า 1 view เช่น กดปุ่มแล้ว ImageView
ช่วยวิ่งไปหน่อยนะครับ สักพักน้อง TextView
ช่วยแสดงวิบวับๆตอนที่ ImageView
ตัวโตนะครับ แล้วที่นี้ทำไงดีน้า
อ่านกันต่อกับ MotionLayout ที่ยังไม่ได้พูดเรื่อง solve ปัญหาด้านบนแต่อย่างใด
เขียนบล็อกเสร็จแล้ว อ่ะฝากร้านหน่อย
และฝากช่องทางใหม่ ทาง Twitter ฮับ