📜 TIL

[Android] NullPointerException -ViewTreeObserver.dispatchOnGlobalLayout

둥굴둥굴둥굴레차 2023. 3. 6. 09:40

LoginFragment.kt에서 NullPointerException 발생

  • 처음 이 에러를 맞닥드렸을 땐 제일 첫 번째 줄에 getBinding이 보여서, binding이 되지 않은건가? 하고 생각했었다.
  • 그러나 알고보니 ViewTreeObserver와 OnGlobalLayout에 대하여 문제가 생겨서 발생한 에러였다
    • 에러코드를 읽을 때 반드시 윗부분만 보지 않고 아래쪽도 좀 챙겨보는 습관을 길러보자!

 

LoginFragment.kt

class LoginFragment : Fragment() {

    private var _binding: FragmentLoginBinding? = null
    private val binding get() = _binding!!
    private var waitTime = 0L

    @SuppressLint("ClickableViewAccessibility")
    override fun onCreateView(

        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {

        _binding = FragmentLoginBinding.inflate(inflater, container, false)

        initView()

				...

        return binding.root
}

	...

    @SuppressLint("ClickableViewAccessibility")
    private fun initView() {

        val showDefaultHeight = TypedValue.applyDimension(
            TypedValue.COMPLEX_UNIT_DIP, 150f,
					resources.displayMetrics
					).toInt()

        val hideDefaultHeight = TypedValue.applyDimension(
            TypedValue.COMPLEX_UNIT_DIP, 150f,
					resources.displayMetrics
					).toInt()


		binding.loginLayout.viewTreeObserver.addOnGlobalLayoutListener(object :
		            ViewTreeObserver.OnGlobalLayoutListener {
		            override fun onGlobalLayout() {
		
		                val viewHeight: Int = binding.loginLayout.height
		                val rootHeight: Int = binding.loginLayout.rootView.height
		                val diff = rootHeight - viewHeight
		
		                if (diff > showDefaultHeight) {
		                    setSNSLoginUIVisibility(binding, View.GONE)
		                } else if (diff < hideDefaultHeight) {
		                    Handler(Looper.getMainLooper()).postDelayed({
		                        setSNSLoginUIVisibility(
		                            binding,
		                            View.VISIBLE
		                        )
		                    }, 10)
		                }

                binding.loginLayout.viewTreeObserver.removeOnGlobalLayoutListener(this)
            }
        })

...

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }

}

 

분석

  • 우선 OnGlobalLayout이 속해있는 로직은 rootView의 height를 계산하여 soft keyboard위에 enter버튼을 적당한 위치에 생성하기 위해 만들어둔 것.
  • ViewTreeObserver를 사용하는 사용법을 검색해보면 항상 따라오는 설명은 다음과 같다
    • ViewTreeObserver를 사용하고 나서는 반드시 removeOnGlobalLayoutListener()를 구현해야 한다.
      • 그렇지 많으면 Observer가 레이아웃에 대한 변경 감지를 무한대로 요청하기 때문에 memory leak이 발생한다.
        • 단 한 번만 레이아웃에 대한 변경을 감지하고 싶다면 addOnGlobalLayoutListener안에 바로 removeOnGlobalLayoutListener를 구현해주면 된다.
  • 하지만 나의 경우 지속적으로 변경을 감지하도록 놔두다가 다음 화면으로 넘어갔을 때 그만 감지하도록 만들고 싶었다.
    • 더 나아가서 다시 원래 화면으로 되돌아왔을 때 다시 Observer가 작동했으면 좋겠다고 생각했다.
      • 이런 경우엔 removeOnGlobalLayoutListener를 프래그먼트의 onStop() 생명주기에 구현해주면 된다.
      • onStop()은 프래그먼트가 화면에서 사라졌을 때 호출되는 매서드

 

 

내가 하고싶은 것?

나는 사용자가 ID창이나 PW창을 클릭해줄 때 마다 Observer를 통해 레이아웃의 크기를 감지하여 키보드 뒤 요소들을 모두 사라지게 하거나 다시 나타나게 하는 기능을 구현하고 싶다.

 

이 말인 즉슨, 계속해서 레이아웃에 대한 변경을 감지할 수 있게 만들고 싶다

 

더불어 다른 화면으로 이동했다가 다시 되돌아 왔을 땐 다시 OnGlobalLayoutListener에 대해 Observer가 또 다시 감시해주었으면 좋겠다.

 

 

LoginFragment.kt

class LoginFragment : Fragment() {

    private var _binding: FragmentLoginBinding? = null
    private val binding get() = _binding!!
    private var waitTime = 0L
		// onDestroyView()에서 사용할 수 있도록 멤버변수로 선언
		private var listener: ViewTreeObserver.OnGlobalLayoutListener? = null

    @SuppressLint("ClickableViewAccessibility")
    override fun onCreateView(

        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {

        _binding = FragmentLoginBinding.inflate(inflater, container, false)

        initView()

				...

        return binding.root
}

override fun onResume() {
        super.onResume()

        initView()

    }

	...

    @SuppressLint("ClickableViewAccessibility")
    private fun initView() {

        val showDefaultHeight = TypedValue.applyDimension(
            TypedValue.COMPLEX_UNIT_DIP, 150f,
					resources.displayMetrics
					).toInt()

        val hideDefaultHeight = TypedValue.applyDimension(
            TypedValue.COMPLEX_UNIT_DIP, 150f,
					resources.displayMetrics
					).toInt()


				listener = ViewTreeObserver.OnGlobalLayoutListener {
            val viewHeight: Int = binding.loginLayout.height
            val rootHeight: Int = binding.loginLayout.rootView.height
            val diff = rootHeight - viewHeight

            Log.d("SH_TAG", "viewHeight = $viewHeight")
            Log.d("SH_TAG", "rootHeight = $rootHeight")
            Log.d("SH_TAG", "diff = $diff")
            Log.d("SH_TAG", "showDefaultHeight = $showDefaultHeight")
            Log.d("SH_TAG", "hideDefaultHeight = $hideDefaultHeight")


            if (diff > showDefaultHeight) {
                setSNSLoginUIVisibility(binding, View.GONE)
            } else if (diff < hideDefaultHeight) {
                Handler(Looper.getMainLooper()).postDelayed({
                    setSNSLoginUIVisibility(
                        binding,
                        View.VISIBLE
                    )
                }, 10)
            }
        }

        binding.loginLayout.viewTreeObserver.addOnGlobalLayoutListener(listener)

}

...

    override fun onStopView() {
		    super.onStop()
        binding.loginLayout.viewTreeObserver.removeOnGlobalLayoutListener(listener)

    }

}

 

 

느낀 점 🤔

Navigator를 적용하는 기나긴 여정이 드디어 끝났다.

정말 뭐 하나 배우는데에 오랜 시간이 걸리는 편..

그렇기에 다른 사람들보다 더 성실해야하지만

또 쉽게 집중하지 못하는 편..

아이고 살아남기 어렵다

그래도 막판에 커피에 힘을 얻어 집중 빡 완료!

오늘도 수고했습니다~~!